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 }