matrix-search

A small nodejs script which indexes a whole matrix account into meilisearch and has a pdf rendering UI
git clone git://archive.git.mtrnord.blog/MTRNord/matrix-search.git
Log | Files | Refs | README | LICENSE

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();