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

tiny-sdk.ts (19768B)


      1 /// A tiny sdk for matrix
      2 
      3 import { GetRoomMessagesRequest, GetRoomMessagesResponse, Received, RoomEventFilter } from "./matrix.js";
      4 import got from "got";
      5 import {
      6     DeviceId,
      7     DeviceLists,
      8     KeysBackupRequest,
      9     KeysClaimRequest,
     10     KeysQueryRequest,
     11     KeysUploadRequest,
     12     OlmMachine,
     13     RequestType,
     14     RoomId,
     15     RoomMessageRequest,
     16     SignatureUploadRequest,
     17     StoreType,
     18     ToDeviceRequest,
     19     UserId
     20 } from "@matrix-org/matrix-sdk-crypto-nodejs";
     21 import { readFileSync, writeFileSync } from "node:fs";
     22 
     23 export const directions = { forward: "f", reverse: "b" } as const;
     24 
     25 export type WhoAmIResponse = {
     26     device_id?: string;
     27     user_id: string;
     28     is_guest?: boolean;
     29 }
     30 
     31 export class MatrixClient {
     32     public userId?: UserId;
     33     private deviceId?: DeviceId;
     34     public olmMachine?: OlmMachine;
     35     private syncRunning: boolean = true;
     36     private roomNameCache: Record<string, string | boolean> = {};
     37 
     38     constructor(private homeserver: string, private token: string) { }
     39 
     40     async start() {
     41         console.log("Starting client");
     42         const whoami = await this.whoami();
     43         console.log(`Logged in as ${whoami.user_id}`);
     44 
     45         this.userId = new UserId(whoami.user_id);
     46 
     47         // Use the existing id or a random one to create a new DeviceId object
     48         // 1. check if we have a device_id
     49         // 2. if we don't, create a random string
     50         // 3. create a new DeviceId object
     51         const device_id = whoami.device_id ?? Math.random().toString(36).substring(7);
     52         console.log(`Device ID: ${device_id}`);
     53         this.deviceId = new DeviceId(device_id);
     54 
     55         this.olmMachine = await OlmMachine.initialize(this.userId, this.deviceId, "./storage/crypto", undefined, StoreType.Sqlite);
     56 
     57         console.log("Client started")
     58     }
     59 
     60     // Reads the storage/bot.json for the syncToken field.
     61     // Also handle the case where the file doesn't exist.
     62     getSyncTokenFromStorage(): { syncToken?: string, previous?: string } | undefined {
     63         try {
     64             const file = readFileSync("./storage/bot.json", "utf-8");
     65             const json = JSON.parse(file);
     66             const syncToken = json.syncToken as string | undefined;
     67             const previous = json.previous as string | undefined;
     68             return { syncToken, previous };
     69         } catch (e) {
     70             return undefined;
     71         }
     72     }
     73 
     74     // Writes the syncToken to the storage/bot.json
     75     // Ensure we dont fail if the file doesn't exist
     76     setSyncTokenToStorage(syncToken: string, previous?: string) {
     77         console.log("Setting sync token to", syncToken);
     78         try {
     79             writeFileSync("./storage/bot.json", JSON.stringify({ syncToken, previous }), { flush: true });
     80         } catch (e) {
     81             console.error("Failed to write sync token", e);
     82             process.exit(1);
     83         }
     84     }
     85 
     86     async * sync(): AsyncGenerator<[string, Record<string, any>], void, void> {
     87         if (!this.olmMachine || !this.token) {
     88             throw new Error("Client not started");
     89         }
     90         while (this.syncRunning) {
     91             let url = "/_matrix/client/v3/sync";
     92             const tokens = this.getSyncTokenFromStorage();
     93             console.log("Syncing with token", tokens?.syncToken);
     94             if (tokens?.syncToken) {
     95                 url += `?since=${tokens.syncToken}`;
     96             }
     97 
     98             let syncResp: Record<string, any>;
     99             try {
    100                 syncResp = await this.getRequest(url, {}, 30 * 1000);
    101                 console.log("Got sync response");
    102             } catch (e) {
    103                 console.error("Failed to sync", e);
    104                 continue;
    105             }
    106 
    107             const toDeviceEvents: Record<string, any>[] | undefined = syncResp.to_device?.events;
    108             const oneTimeKeyCounts: Record<string, number> = syncResp.device_one_time_keys_count;
    109             const unusedFallbackKeys: Array<string> = syncResp.device_unused_fallback_key_types;
    110             const leftDevices: UserId[] | undefined = syncResp.device_lists?.left?.map((u: string) => new UserId(u));
    111             const changedDevices: UserId[] | undefined = syncResp.device_lists?.changed?.map((u: string) => new UserId(u));
    112 
    113 
    114             const deviceList = new DeviceLists(changedDevices, leftDevices);
    115             await this.olmMachine.receiveSyncChanges(
    116                 JSON.stringify(toDeviceEvents) ?? "[]",
    117                 deviceList,
    118                 oneTimeKeyCounts,
    119                 unusedFallbackKeys,
    120             );
    121 
    122             const outgoingRequests = await this.olmMachine.outgoingRequests();
    123             // Send outgoing requests
    124             for (const request of outgoingRequests) {
    125                 // Switch on the request type
    126                 try {
    127                     switch (request.type) {
    128                         case RequestType.KeysUpload: {
    129                             const keysUploadRequest = request as KeysUploadRequest;
    130                             const resp = await this.postRequest("/_matrix/client/v3/keys/upload", {}, keysUploadRequest.body, 30 * 1000);
    131                             await this.olmMachine.markRequestAsSent(request.id, request.type, JSON.stringify(resp));
    132                             break;
    133                         }
    134                         case RequestType.KeysBackup: {
    135                             const keysBackupRequest = request as KeysBackupRequest;
    136                             const resp = await this.putRequest("/_matrix/client/v3/room_keys/keys", {}, keysBackupRequest.body, 30 * 1000);
    137                             await this.olmMachine.markRequestAsSent(request.id, request.type, JSON.stringify(resp));
    138                             break;
    139                         }
    140                         case RequestType.KeysClaim: {
    141                             const keysClaimRequest = request as KeysClaimRequest;
    142                             const resp = await this.postRequest("/_matrix/client/v3/keys/claim", {}, keysClaimRequest.body, 30 * 1000);
    143                             await this.olmMachine.markRequestAsSent(request.id, request.type, JSON.stringify(resp));
    144                             break;
    145                         }
    146                         case RequestType.KeysQuery: {
    147                             const keysQueryRequest = request as KeysQueryRequest;
    148                             const resp = await this.postRequest("/_matrix/client/v3/keys/query", {}, keysQueryRequest.body, 30 * 1000);
    149                             await this.olmMachine.markRequestAsSent(request.id, request.type, JSON.stringify(resp));
    150                             break;
    151                         }
    152                         case RequestType.SignatureUpload: {
    153                             const signatureUploadRequest = request as SignatureUploadRequest;
    154                             const resp = await this.postRequest("/_matrix/client/v3/keys/signatures/upload", {}, signatureUploadRequest.body, 30 * 1000);
    155                             await this.olmMachine.markRequestAsSent(request.id, request.type, JSON.stringify(resp));
    156                             break;
    157                         }
    158                         case RequestType.ToDevice: {
    159                             const toDeviceRequest = request as ToDeviceRequest;
    160                             const resp = await this.postRequest(`/_matrix/client/v3/sendToDevice/${toDeviceRequest.eventType}/${toDeviceRequest.txnId}`, {}, toDeviceRequest.body, 30 * 1000);
    161                             await this.olmMachine.markRequestAsSent(request.id, request.type, JSON.stringify(resp));
    162                             break;
    163                         }
    164                         case RequestType.RoomMessage: {
    165                             const roomMessageRequest = request as RoomMessageRequest;
    166                             const resp = await this.postRequest(`/_matrix/client/v3/rooms/${roomMessageRequest.roomId}/send/${roomMessageRequest.eventType}/${roomMessageRequest.txnId}`, {}, roomMessageRequest.body, 30 * 1000);
    167                             await this.olmMachine.markRequestAsSent(request.id, request.type, JSON.stringify(resp));
    168                             break;
    169                         }
    170                         default:
    171                             console.error("Unknown request type", request);
    172                     }
    173                 } catch (e) {
    174                     console.error("Failed to send request", e);
    175                     // We retry
    176                     continue;
    177                 }
    178             }
    179 
    180             // Save the sync token
    181             console.log("Next:", syncResp.next_batch as string, "Current:", tokens?.syncToken);
    182             this.setSyncTokenToStorage(syncResp.next_batch as string, tokens?.syncToken);
    183 
    184             if (syncResp.next_batch === tokens?.previous || syncResp.next_batch === tokens?.syncToken) {
    185                 // We already synced this token
    186                 console.log("Already synced this token, waiting for new events");
    187                 continue;
    188             }
    189 
    190             // decrypt all events here. for our usecase we only care about joined rooms
    191             syncResp.rooms.join = await this.decryptTimeline(syncResp);
    192 
    193             // parse new events from the sync response
    194             const joinedRooms = syncResp.rooms.join as Record<string, any>;
    195 
    196             // Loop over the joined rooms object (we need the key which is the room id and the events within)
    197             for (const [roomId, room] of Object.entries(joinedRooms)) {
    198                 // Loop over the events in the room
    199                 for (const event of room.timeline.events) {
    200                     yield [roomId, event];
    201                 }
    202             }
    203         }
    204     }
    205 
    206     private async decryptTimeline(syncResp: any) {
    207         const decryptedEvents: Record<string, Record<string, any>[]> = {};
    208         for (const [roomId, room] of Object.entries(syncResp.rooms.join as Record<string, any>)) {
    209             const decryptedRoom = await this.decryptListOfEvents(room.timeline.events as Record<string, any>[], roomId);
    210             decryptedEvents[roomId] = decryptedRoom;
    211         }
    212         return decryptedEvents;
    213     }
    214 
    215     async decryptListOfEvents(events: Record<string, any>[], roomId: string): Promise<Record<string, any>[]> {
    216         return await Promise.all(events.map(async (event) => {
    217             return await this.decryptEvent(event, roomId);
    218         }));
    219     }
    220 
    221     async decryptEvent(event: Record<string, any>, roomId: string): Promise<Record<string, any>> {
    222         if (event["type"] === "m.room.member") {
    223             if (event.content.membership !== 'join' && event.content.membership !== 'invite') return event;
    224             await this.addTrackedUsers([event["state_key"] as string])
    225             return event;
    226         }
    227         if (event.type === "m.room.encrypted") {
    228             const members = await this.getRoomMembers(roomId, ['join', 'invite']);
    229             await this.addTrackedUsers(members.map(e => e["state_key"] as string))
    230             try {
    231                 const rawEvent = await this.olmMachine?.decryptRoomEvent(JSON.stringify(event), new RoomId(roomId));
    232                 if (rawEvent) {
    233                     event = JSON.parse(rawEvent.event);
    234                     return event;
    235                 } else {
    236                     console.warn("Failed to decrypt event1", event);
    237                     return event;
    238                 }
    239             } catch (e) {
    240                 console.warn("Failed to decrypt event2", e);
    241                 return event;
    242             }
    243         }
    244         return event;
    245     }
    246     private async getRoomMembers(roomId: string, membership: string[]) {
    247         const resp: { chunk: Record<string, any>[]; } = await this.getRequest(`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/members`, { membership }, 30 * 1000);
    248         return resp.chunk;
    249     }
    250 
    251 
    252     async whoami(): Promise<WhoAmIResponse> {
    253         return await this.getRequest("/_matrix/client/v3/account/whoami", {}, 30 * 1000) as WhoAmIResponse;
    254     }
    255 
    256     async sendFile(roomId: string, event_id: string, body: string, file: Buffer) {
    257         // Upload with authenticated media endpoint
    258         const mediaResponse = await this.postRequest(`/_matrix/media/v3/upload?filename=${encodeURIComponent(roomId.replaceAll("!", "").replaceAll(":", "_"))}.pdf`, {}, file, 30 * 1000, "application/pdf");
    259         const mediaUrl = mediaResponse.content_uri;
    260 
    261         // Send a notice to the room telling the user the file is ready
    262         const content = {
    263             msgtype: "m.notice",
    264             body: body,
    265             "m.relates_to": {
    266                 "m.in_reply_to": {
    267                     event_id: event_id
    268                 }
    269             }
    270         };
    271 
    272         const txnId = `${Date.now()}-${Math.random()}`;
    273 
    274         await this.putRequest(`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`, {}, JSON.stringify(content).toString(), 30 * 1000);
    275 
    276         // Send file to the room
    277         const txnId2 = `${Date.now()}-${Math.random()}`;
    278 
    279         const fileContent = {
    280             msgtype: "m.file",
    281             body: `${roomId.replaceAll("!", "").replaceAll(":", "_")}.pdf`,
    282             url: mediaUrl,
    283             info: {
    284                 mimetype: "application/pdf",
    285                 size: file.length
    286             }
    287         };
    288         await this.putRequest(`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(txnId2)}`, {}, JSON.stringify(fileContent), 30 * 1000);
    289     }
    290 
    291     async redactEvent(roomId: string, eventId: string, reason?: string) {
    292         await this.postRequest(`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}`, {}, JSON.stringify({ reason }), 30 * 1000);
    293     }
    294 
    295     // Fetches the room name from the cache or the server
    296     async getRoomName(roomId: string): Promise<string | undefined> {
    297         if (typeof this.roomNameCache[roomId] === "string") {
    298             return this.roomNameCache[roomId] as string;
    299         } else if (this.roomNameCache[roomId] === false) {
    300             return;
    301         }
    302 
    303         try {
    304             const room_name_resp = await this.getRequest(`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`, {}, 30 * 1000);
    305             const room_name = room_name_resp.name;
    306             if (room_name) {
    307                 this.roomNameCache[roomId] = room_name;
    308                 return room_name as string;
    309             }
    310         } catch {
    311             this.roomNameCache[roomId] = false;
    312             return;
    313         }
    314     }
    315 
    316     async getRequest(path: string, query: Record<string, any>, timeout: number): Promise<any> {
    317         if (!this.token) {
    318             throw new Error("Client not started");
    319         }
    320         return got.get(`${this.homeserver}${path}`, {
    321             searchParams: query, timeout: {
    322                 request: timeout
    323             }, headers: { "Content-Type": "application/json", "Authorization": `Bearer ${this.token}` }
    324         }).json();
    325     }
    326 
    327     async postRequest(path: string, query: Record<string, any>, body: string | Buffer, timeout: number, content_type = "application/json"): Promise<any> {
    328         if (!this.token) {
    329             throw new Error("Client not started");
    330         }
    331         return await got.post(`${this.homeserver}${path}`, {
    332             searchParams: query, body, timeout: {
    333                 request: timeout
    334             }, headers: { "Content-Type": content_type, "Authorization": `Bearer ${this.token}` }
    335         }).json();
    336     }
    337 
    338     async putRequest(path: string, query: Record<string, any>, body: string, timeout: number, content_type = "application/json"): Promise<any> {
    339         if (!this.token) {
    340             throw new Error("Client not started");
    341         }
    342         return await got.put(`${this.homeserver}${path}`, {
    343             searchParams: query, body, timeout: {
    344                 request: timeout
    345             }, headers: { "Content-Type": content_type, "Authorization": `Bearer ${this.token}` }
    346         }).json();
    347     }
    348 
    349     async getJoinedRooms(): Promise<string[]> {
    350         const response = await this.getRequest("/_matrix/client/v3/joined_rooms", {}, 30 * 1000);
    351         return response.joined_rooms as string[];
    352     }
    353 
    354     async * getRoomEvents(
    355         room: string,
    356         direction: "forward" | "reverse",
    357         filter?: RoomEventFilter
    358     ): AsyncGenerator<Received<any>[], void, void> {
    359         const path = `/_matrix/client/v3/rooms/${encodeURIComponent(room)}/messages`;
    360         const base: GetRoomMessagesRequest = {
    361             ...(direction && { dir: directions[direction] }),
    362             ...(filter && { filter: JSON.stringify(filter) }),
    363         };
    364 
    365         let from: string | undefined;
    366         do {
    367             const query: GetRoomMessagesRequest = { ...base, ...(from && { from }) };
    368 
    369             // We request but try at least 5 times
    370             let response: GetRoomMessagesResponse | undefined = undefined;
    371             for (let i = 0; i < 15; i++) {
    372                 try {
    373                     response = await this.getRequest(path, query, 120 * 1000);
    374                     break;
    375                 } catch (e) {
    376                     console.error("Failed to get messages, retrying", e);
    377                     // Wait 1s before retrying
    378                     await new Promise((resolve) => setTimeout(resolve, 1000));
    379                 }
    380             }
    381 
    382             if (!response) {
    383                 console.error("Failed to get messages");
    384                 break;
    385             }
    386             from = response.end;
    387             yield response.chunk;
    388         } while (from);
    389     }
    390 
    391     private async addTrackedUsers(users: string[]) {
    392         const uids = users.map((u) => new UserId(u));
    393         await this.olmMachine?.updateTrackedUsers(uids);
    394 
    395         const keysClaim = await this.olmMachine?.getMissingSessions(uids);
    396         if (keysClaim) {
    397             const keysClaimRequest = keysClaim;
    398             const resp = await this.postRequest("/_matrix/client/v3/keys/claim", {}, keysClaimRequest.body, 30 * 1000);
    399             await this.olmMachine?.markRequestAsSent(keysClaim.id, keysClaim.type, JSON.stringify(resp));
    400         }
    401     }
    402 
    403     // // MAS specific login
    404     // async masLogin() {
    405     //     const auth_issuer_resp: { issuer: string } = await got.get(`${this.homeserver}/_matrix/client/unstable/org.matrix.msc2965/auth_issuer`).json();
    406     //     const issuer = auth_issuer_resp.issuer;
    407 
    408     //     const openid_config: { token_endpoint: string; device_authorization_endpoint: string; registration_endpoint: string; } = await got.get(issuer + "/.well-known/openid-configuration").json();
    409     //     const token_endpoint = openid_config.token_endpoint;
    410     //     const device_authorization_endpoint = openid_config.device_authorization_endpoint;
    411     //     const registration_endpoint = openid_config.registration_endpoint;
    412 
    413     //     // Register a client with `urn:ietf:params:oauth:grant-type:device_code` and `refresh_token` grant types
    414     //     const request = {
    415     //         application_type: "native",
    416     //         client_name: "Matrix Search",
    417     //         grant_types: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"],
    418     //         response_types: [],
    419     //         token_endpoint_auth_method: "none"
    420     //     };
    421     //     const client_data: { client_id: string; } = await got.post(registration_endpoint, {
    422     //         json: request
    423     //     }).json();
    424 
    425     //     // Get a device code
    426     //     const deviceID = Math.random().toString(36).substring(7);
    427     //     const device_code_resp = await got.post(device_authorization_endpoint, {
    428     //         json: {
    429     //             client_id: client_data.client_id,
    430     //             scope: `urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceID}`
    431     //         }
    432     //     }).json();
    433     // }
    434 }