bot.ts (6038B)
1 import Indexer from "./indexer.js"; 2 import Config from "./config.js"; 3 import fs from "node:fs"; 4 import { MatrixClient } from "./tiny-sdk.js"; 5 import { renderPDFToBufferWithData } from "./renderer.js"; 6 7 // Ensures no matrix IDs are in the content.body 8 function cleanContent(content: Record<string, any>) { 9 if (typeof content !== "object") return content; 10 if (content.body) { 11 content.body = content.body.replaceAll(/@[a-zA-Z0-9_\-.=]+:[a-zA-Z0-9\-.]+/g, "<mxid>"); 12 } 13 return content; 14 } 15 16 type BackfillState = { 17 rooms: string[]; 18 }; 19 20 async function backfill(client: MatrixClient, indexer: Indexer) { 21 // Check if we have a state file 22 const filePath = "./storage/backfillState.json"; 23 if (!fs.existsSync(filePath)) { 24 console.info("No backfill state found, writing empty state file"); 25 fs.writeFileSync(filePath, "{\"rooms\":[]}"); 26 } 27 28 const stateJson = fs.readFileSync(filePath, "utf-8"); 29 const state: BackfillState = JSON.parse(stateJson); 30 31 console.info("Indexing existing messages") 32 const joined_rooms = await client.getJoinedRooms(); 33 34 // Remvoe rooms we've already indexed. 35 // The already indexed are a list of strings in the rooms object on our state 36 const newRooms = joined_rooms.filter(room => !state.rooms.includes(room)); 37 38 // Save the state file 39 fs.writeFileSync(filePath, JSON.stringify(state)); 40 41 const editedEvents: string[] = []; 42 43 for (const room of newRooms) { 44 const pages = client.getRoomEvents(room, "reverse", { types: ["m.room.message"] }); 45 for await (const page of pages) { 46 for (const rawEvent of page) { 47 let event: Record<string, any> = rawEvent as Record<string, any>; 48 if (editedEvents.includes(event["event_id"] as string)) { 49 continue; 50 } 51 if (event.content["m.relates_to"]) { 52 if (event.content["m.relates_to"].rel_type === "m.replace") { 53 editedEvents.push(event.content["m.relates_to"].event_id as string); 54 } 55 } 56 57 event = await client.decryptEvent(event, room); 58 void handleMessages(indexer, client, room, event, true); 59 60 } 61 } 62 63 // Add the room to the state and save it 64 state.rooms.push(room); 65 fs.writeFileSync(filePath, JSON.stringify(state)); 66 } 67 } 68 69 function normalizeEventId(eventId: string) { 70 return eventId.replaceAll("$", "").replaceAll(":", "_").replaceAll(".", "_").replaceAll("!", "_"); 71 } 72 73 function convertEventToDocument(event: any, roomId: string, room_name?: string) { 74 const doc: { 75 id: string, 76 sender: string, 77 content: any, 78 room_id: string, 79 origin_server_ts: string, 80 room_name?: string 81 } = { 82 id: normalizeEventId(event["event_id"] as string), 83 sender: event["sender"], 84 content: cleanContent(event["content"] as Record<string, any>), 85 room_id: roomId, 86 origin_server_ts: event["origin_server_ts"], 87 } 88 if (room_name) { 89 doc.room_name = room_name 90 } 91 92 return doc 93 } 94 95 async function handleMessages(indexer: Indexer, client: MatrixClient, roomId: string, event: any, historical = false) { 96 if (event["content"]["msgtype"] === "m.text") { 97 const user = client.userId?.toString(); 98 // If command and sender is bot user then handle as a command and not index 99 if (event["content"]["body"].startsWith("!") && event["sender"] === user && !historical) { 100 // Get the command directly after the ! (e.g. !search -> search) 101 const command = event["content"]["body"].split(" ")[0].substring(1); 102 const args: string[] = event["content"]["body"].split(" ").slice(1); 103 104 // Render the amount of events given in the argument (e.g. !last 5) as a pdf and send it into the room 105 if (command === "last") { 106 console.info(`Received "last" command in room ${roomId}`); 107 108 const amount = parseInt(args[0]); 109 console.info(`Rendering last ${amount} messages as PDF`); 110 const results = await indexer.search("", roomId, undefined, amount); 111 console.info(`Got ${results.hits.length} results`); 112 const pdf = await renderPDFToBufferWithData(results, "", roomId, undefined); 113 114 const event_id = event["event_id"] as string; 115 await client.sendFile(roomId, event_id, "Here is the PDF with the last " + amount + " messages", pdf); 116 117 // Redact the original command message 118 await client.redactEvent(roomId, event_id, "Redacted by bot"); 119 return; 120 } 121 } 122 123 124 //console.info(`Received message in room ${roomId}`); 125 if (event.content["m.relates_to"]) { 126 if (event.content["m.relates_to"].rel_type === "m.replace") { 127 //console.info(`Removing original event ${event.content["m.relates_to"].event_id}`); 128 await indexer.delete(normalizeEventId(event.content["m.relates_to"].event_id as string)); 129 } 130 } 131 132 const room_name = await client.getRoomName(roomId); 133 134 void indexer.insert(convertEventToDocument(event, roomId, room_name)); 135 } 136 } 137 138 async function handleSync(client: MatrixClient, indexer: Indexer) { 139 console.info("Starting sync"); 140 const events = client.sync(); 141 for await (const [roomId, event] of events) { 142 void handleMessages(indexer, client, roomId, event) 143 } 144 } 145 146 async function run() { 147 const config = new Config(); 148 const homeserverUrl = config.homeserverUrl; 149 const accessToken = config.accessToken; 150 const indexer = new Indexer(); 151 152 const client = new MatrixClient(homeserverUrl, accessToken); 153 await client.start(); 154 155 await Promise.allSettled([handleSync(client, indexer), backfill(client, indexer)]); 156 157 158 await client.start(); 159 console.info("Bot started!"); 160 161 162 } 163 164 await run();