import * as dotenv from 'dotenv'; import { MatrixClient } from 'matrix-js-sdk'; import fs from 'fs'; dotenv.config(); console.log('🚀 Starting Ancestor Quote Bot...\n'); const deviceId = process.env.MATRIX_DEVICE_ID || 'ANCESTORBOT'; const client = new MatrixClient({ accessToken: process.env.MATRIX_ACCESS_TOKEN, baseUrl: process.env.MATRIX_HOME_SERVER, deviceId, }); // The Rust crypto backend uses an in-memory store here (no IndexedDB in Node), // so after a restart it 'forgets' one-time keys it already uploaded. Re-uploading // an existing OTK id makes Synapse reject the request with a 400, the SDK never // marks it as sent, and it retries forever — which blocks decryption entirely. // Rewrite that specific 400 into a synthetic success: the empty count makes the // Rust machine mint fresh OTK ids on the next tick, which then upload cleanly. function installOtkCollisionWorkaround(client) { const http = client.http; const authedRequest = http.authedRequest.bind(http); http.authedRequest = async function (method, path, queryParams, body, opts) { try { return await authedRequest(method, path, queryParams, body, opts); } catch (err) { if ( method === 'POST' && /\/keys\/upload$/.test(path) && err?.httpStatus === 400 && /already exists/.test(err?.message || '') ) { console.warn('âš™ī¸ One-time-key collision on keys/upload; telling Rust crypto to mint fresh keys'); return '{"one_time_key_counts":{}}'; } throw err; } }; } installOtkCollisionWorkaround(client); function loadQuotes() { try { const quotesFile = './quotes.json'; if (fs.existsSync(quotesFile)) { return JSON.parse(fs.readFileSync(quotesFile, 'utf8')); } } catch (err) { console.error('Error loading quotes:', err.message); } return []; } (async () => { const quotes = loadQuotes(); if (quotes.length === 0) { console.error('❌ No quotes found! Please ensure quotes.json is present.'); process.exit(1); } console.log(`✅ Loaded ${quotes.length} Ancestor quotes`); const token = process.env.MATRIX_ACCESS_TOKEN; const server = process.env.MATRIX_HOME_SERVER; if (!token || !server) { console.error('❌ Missing required environment variables!'); console.error(' Required: MATRIX_ACCESS_TOKEN, MATRIX_HOME_SERVER'); console.error(' See .env.example for guidance.'); process.exit(1); } console.log(`🏠 Server: ${server}`); try { const whoami = await client.whoami(); const userId = whoami.user_id; if (!userId || !userId.startsWith('@')) { console.error(`❌ whoami returned invalid user ID: ${userId}`); process.exit(1); } client.credentials.userId = userId; console.log(`👤 Bot Identity: ${userId} (device ${deviceId})`); // Enable end-to-end encryption so the bot can read and reply in // encrypted rooms. The Rust/Olm WASM backend needs no native // compile. useIndexedDB=false keeps everything in memory (no // IndexedDB in Node). await client.initRustCrypto({ useIndexedDB: false }); console.log('🔐 E2EE crypto initialised (Rust/Olm WASM)'); await client.startClient(); console.log('✅ MatrixClient connected and authenticated\n'); const joinedRooms = client.getRooms(); console.log(`🏠 Current rooms: ${joinedRooms.length}`); joinedRooms.forEach((r) => { console.log(` - ${r.roomId} membership="${r.getMyMembership()}"`); }); console.log(''); const cmd = (process.env.BOT_COMMAND || '!quote').toLowerCase(); console.log(`🔊 Listening for command: "${cmd}"`); // Auto-accept invites so the bot can be invited into rooms. client.on('RoomMember.membership', (event, member) => { if (member.userId === userId && member.membership === 'invite') { const roomId = event.getRoomId(); console.log(`📩 Invited to ${roomId}, joining...`); client.joinRoom(roomId).catch((err) => { console.error('Failed to join room:', err.message); }); } }); // Respond to messages in any room this bot is a member of. client.on('event', (event) => { const type = event.getType(); const eventRoomId = event.getRoomId(); // If an event still arrives encrypted, decryption failed — the // room is likely set to only share keys with verified sessions. if (type === 'm.room.encrypted') { console.log(`🔒 [${eventRoomId}] could not decrypt message. ` + `The room may be set to share keys only with verified sessions; ` + `change room encryption settings to include this bot's device, or verify it.`); return; } if (type !== 'm.room.message') return; const sender = event.getSender(); if (sender === userId) return; // skip replies from ourselves const room = eventRoomId ? client.getRoom(eventRoomId) : null; if (!room) { console.log(`â„šī¸ [debug] got m.room.message for unknown room: ${eventRoomId}`); return; } const membership = room.getMyMembership(); if (membership !== 'join') { console.log(`â„šī¸ [debug] [${room.roomId}] m.room.message while membership="${membership}" — not replying`); return; } const content = event.getContent(); if (!content || typeof content.body !== 'string') { console.log(`â„šī¸ [debug] [${room.roomId}] m.room.message without text body`); return; } const lower = content.body.toLowerCase().trim(); const matches = lower === cmd || lower.startsWith(cmd + ' '); console.log(`đŸ’Ŧ [${room.roomId}] <${sender}> "${content.body}" (listening for "${cmd}", match=${matches})`); if (!matches) return; const quote = quotes[Math.floor(Math.random() * quotes.length)]; client.sendTextMessage(room.roomId, quote) .then(() => console.log(`🎃 [${room.roomId}] ${quote}`)) .catch((err) => console.error('Failed to send quote:', err.message)); }); console.log('🤖 Bot is running!'); console.log(` Invite this bot to a room and type ${process.env.BOT_COMMAND || '!quote'} to get an Ancestor quote.\n`); } catch (err) { console.error('❌ Fatal error starting bot:', err.message); console.error(err.stack); process.exit(1); } process.on('unhandledRejection', (reason, promise) => { console.error('🚨 Unhandled rejection:', reason?.message || reason); process.exit(1); }); setInterval(() => {}, 60000); })();