diff --git a/.gitignore b/.gitignore index 39483ff..3984133 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ bun.lock # Telegram MTProto session data (contains auth secrets) data/telegram-mtproto/ logs/ + +# Matrix session data (contains crypto keys) +data/matrix/ diff --git a/package-lock.json b/package-lock.json index 9b63160..a7b9399 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@atproto/api": "^0.19.1", + "@atproto/api": "^0.19.3", "@clack/prompts": "^0.11.0", "@hapi/boom": "^10.0.1", "@letta-ai/letta-client": "^1.7.12", @@ -28,7 +28,7 @@ "openai": "^6.17.0", "pino": "^10.3.1", "qrcode-terminal": "^0.12.0", - "sharp": "^0.34.1", + "sharp": "^0.33.5", "telegramify-markdown": "^1.0.0", "tsx": "^4.21.0", "typescript": "^5.9.3", @@ -53,9 +53,17 @@ "node": ">=20" }, "optionalDependencies": { + "@matrix-org/matrix-sdk-crypto-wasm": "^1.3.0", + "@matrix-org/olm": "^3.2.15", "@slack/bolt": "^4.6.0", + "@types/better-sqlite3": "^7.6.13", + "@types/matrix-js-sdk": "28.1.0", "@whiskeysockets/baileys": "6.7.21", + "better-sqlite3": "^11.10.0", "discord.js": "^14.25.1", + "fake-indexeddb": "^6.2.5", + "indexeddbshim": "^16.1.0", + "matrix-js-sdk": "^28.2.0", "prebuilt-tdlib": "^0.1008060.0", "slackify-markdown": "^5.0.0", "tdl": "^8.0.2" @@ -176,6 +184,16 @@ "zod": "^3.23.8" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@borewit/text-codec": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz", @@ -386,9 +404,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", "license": "MIT", "optional": true, "dependencies": { @@ -811,6 +829,13 @@ "node": ">=18" } }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "license": "MIT", + "optional": true + }, "node_modules/@grammyjs/types": { "version": "3.23.0", "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.23.0.tgz", @@ -833,18 +858,18 @@ "license": "BSD-3-Clause" }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", "cpu": [ "arm64" ], @@ -860,13 +885,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.0.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", "cpu": [ "x64" ], @@ -882,13 +907,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.0.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", "cpu": [ "arm64" ], @@ -902,9 +927,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", "cpu": [ "x64" ], @@ -918,9 +943,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", "cpu": [ "arm" ], @@ -934,9 +959,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", "cpu": [ "arm64" ], @@ -982,9 +1007,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", "cpu": [ "s390x" ], @@ -998,9 +1023,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", "cpu": [ "x64" ], @@ -1014,9 +1039,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", "cpu": [ "arm64" ], @@ -1030,9 +1055,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", "cpu": [ "x64" ], @@ -1046,9 +1071,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", "cpu": [ "arm" ], @@ -1064,13 +1089,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.0.5" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", "cpu": [ "arm64" ], @@ -1086,7 +1111,7 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.0.4" } }, "node_modules/@img/sharp-linux-ppc64": { @@ -1134,9 +1159,9 @@ } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", "cpu": [ "s390x" ], @@ -1152,13 +1177,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.0.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", "cpu": [ "x64" ], @@ -1174,13 +1199,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.0.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", "cpu": [ "arm64" ], @@ -1196,13 +1221,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", "cpu": [ "x64" ], @@ -1218,20 +1243,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", "cpu": [ "wasm32" ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@emnapi/runtime": "^1.2.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -1260,9 +1285,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", "cpu": [ "ia32" ], @@ -1279,9 +1304,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", "cpu": [ "x64" ], @@ -1385,6 +1410,367 @@ "@letta-ai/letta-code": "0.18.2" } }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@letta-ai/letta-code/node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@letta-ai/letta-code/node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1424,9 +1810,9 @@ } }, "node_modules/@letta-ai/letta-code/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -1481,6 +1867,50 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@letta-ai/letta-code/node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/@letta-ai/letta-code/node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", @@ -1496,6 +1926,112 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@matrix-org/matrix-sdk-crypto-wasm": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-1.3.0.tgz", + "integrity": "sha512-vQ5PVppKu1PY7xy7QDw+RJLYLGFKhJyxLqjXHr0uEUJwfvz2IH2njTLXzrz77dOo9qacxJ9/YNOTe0Hl+98N0A==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@matrix-org/olm": { + "version": "3.2.15", + "resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz", + "integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@npmcli/move-file/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/move-file/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@npmcli/move-file/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -2230,6 +2766,26 @@ "license": "MIT", "optional": true }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2291,6 +2847,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/events": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz", + "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==", + "license": "MIT", + "optional": true + }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -2746,6 +3309,13 @@ "real-require": "^0.2.0" } }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2780,6 +3350,50 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aggregate-error/node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/another-json": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz", + "integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/ansi-align": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", @@ -2869,6 +3483,28 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -2878,6 +3514,13 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/argsarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/argsarray/-/argsarray-0.0.1.tgz", + "integrity": "sha512-u96dg2GcAKtpTrBdDoFIM7PjcBA+6rSP0OR94MOReNRyUECL6MtQt5XXmRr4qrftYaef9+l5hcpO5te7sML1Cg==", + "license": "WTFPL", + "optional": true + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2972,6 +3615,23 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base-x": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", + "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", + "license": "MIT", + "optional": true + }, + "node_modules/base64-arraybuffer-es6": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/base64-arraybuffer-es6/-/base64-arraybuffer-es6-3.1.0.tgz", + "integrity": "sha512-QKKtftiSrKjilihGNLXxnrb9LJj7rnEdB1cYAqVpekFy0tisDklAf1RAgvpm0HsGYx9sv7FUbgpsrfwTyCPVLg==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2992,6 +3652,18 @@ ], "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -3001,6 +3673,28 @@ "node": "*" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -3096,6 +3790,41 @@ "balanced-match": "^1.0.0" } }, + "node_modules/bs58": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", + "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "base-x": "^4.0.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -3136,6 +3865,135 @@ "node": ">= 0.8" } }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/cacache/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/cacache/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/cacheable": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.2.tgz", @@ -3191,6 +4049,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/canvas": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.1.tgz", + "integrity": "sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -3255,6 +4128,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC", + "optional": true + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/cli-boxes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", @@ -3330,6 +4220,19 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3348,6 +4251,26 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -3368,6 +4291,13 @@ "node": ">= 0.8" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true + }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -3402,6 +4332,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -3478,6 +4415,13 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT", + "optional": true + }, "node_modules/curve25519-js": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz", @@ -3535,6 +4479,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -3594,6 +4554,13 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3749,16 +4716,49 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "once": "^1.4.0" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -3771,6 +4771,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT", + "optional": true + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3952,6 +4959,36 @@ "license": "MIT", "optional": true }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/eventtargeter": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/eventtargeter/-/eventtargeter-0.9.0.tgz", + "integrity": "sha512-40f2UpTpS7v2GnXWsMpEUkKoMOlgTDrijhCx4Hq7zvsBhLihA7dn0Ayicb4FO/MOIzD5mLHsLK7/NYqx39LqaA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -4023,6 +5060,16 @@ "node": ">=0.10.0" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/fast-copy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", @@ -4114,6 +5161,13 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT", + "optional": true + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -4242,6 +5296,46 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT", + "optional": true + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4265,6 +5359,79 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/gauge/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/gaxios": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", @@ -4373,6 +5540,13 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT", + "optional": true + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -4565,6 +5739,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hashery": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.4.0.tgz", @@ -4619,6 +5800,13 @@ "integrity": "sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ==", "license": "MIT" }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause", + "optional": true + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -4639,6 +5827,34 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -4652,6 +5868,16 @@ "node": ">= 14" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -4689,6 +5915,23 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/immediate": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.3.0.tgz", + "integrity": "sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==", + "license": "MIT", + "optional": true + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", @@ -4702,6 +5945,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/indexeddbshim": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/indexeddbshim/-/indexeddbshim-16.1.0.tgz", + "integrity": "sha512-cilsC67db5kWeqK+GDPTQBZFf+ZxiAlPx+vJoOJwqoWPqGE5A4noSSD1qH8sGfysAI6mqGxsmkLfkC6T9QbY1w==", + "license": "(MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "canvas": "^3.2.0", + "eventtargeter": "0.9.0", + "sync-promise-expanded": "^1.0.0", + "typeson": "9.0.4", + "typeson-registry": "12.0.0", + "websql-configurable": "^3.0.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "license": "ISC", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4884,6 +6164,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4917,6 +6207,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/is-buffer": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", @@ -5062,6 +6358,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "license": "MIT", + "optional": true + }, "node_modules/is-npm": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", @@ -5236,6 +6539,13 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==", + "license": "MIT", + "optional": true + }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", @@ -5396,6 +6706,20 @@ "license": "MIT", "optional": true }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -5467,6 +6791,97 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/make-fetch-happen/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -5487,6 +6902,50 @@ "node": ">= 0.4" } }, + "node_modules/matrix-events-sdk": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz", + "integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/matrix-js-sdk": { + "version": "28.2.0", + "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-28.2.0.tgz", + "integrity": "sha512-YENmPaiGgWwCqoYWoL/8oD7QPWd6M/A0xdNhC4yMSiFny419AjUdPQk/EbM8RTSzQV27F79llhWisnz+/AXdaA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@matrix-org/matrix-sdk-crypto-wasm": "^1.2.3-alpha.0", + "another-json": "^0.2.0", + "bs58": "^5.0.0", + "content-type": "^1.0.4", + "jwt-decode": "^3.1.2", + "loglevel": "^1.7.1", + "matrix-events-sdk": "0.0.1", + "matrix-widget-api": "^1.6.0", + "oidc-client-ts": "^2.2.4", + "p-retry": "4", + "sdp-transform": "^2.14.1", + "unhomoglyph": "^1.0.6", + "uuid": "9" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/matrix-widget-api": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz", + "integrity": "sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@types/events": "^3.0.0", + "events": "^3.2.0" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -6347,6 +7806,19 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -6380,6 +7852,188 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-collect/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-fetch/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT", + "optional": true + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6443,6 +8097,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT", + "optional": true + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -6452,6 +8113,19 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.88.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz", + "integrity": "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -6499,6 +8173,31 @@ } } }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -6511,6 +8210,69 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/node-gyp/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/node-gyp/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/node-schedule": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz", @@ -6525,6 +8287,46 @@ "node": ">=6" } }, + "node_modules/noop-fn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/noop-fn/-/noop-fn-1.0.0.tgz", + "integrity": "sha512-pQ8vODlgXt2e7A3mIbFDlizkr46r75V+BJxVAyat8Jl7YmI513gG5cfyRL0FedKraoZ+VAouI1h4/IWpus5pcQ==", + "license": "MIT", + "optional": true + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -6548,6 +8350,20 @@ ], "license": "MIT" }, + "node_modules/oidc-client-ts": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.5.0.tgz", + "integrity": "sha512-JZ/Sp+AoML4sBWCn8ShAjnIMKx3GXwU/8sQY2btRPOUS8kBZltC2dFqOdN5Mimc4g7oVGSTC/bVDBviYcuud9g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "crypto-js": "^4.2.0", + "jwt-decode": "^3.1.2" + }, + "engines": { + "node": ">=12.13.0" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -6645,6 +8461,22 @@ "node": ">=4" } }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-queue": { "version": "6.6.2", "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", @@ -6767,6 +8599,16 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -6951,6 +8793,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prebuilt-tdlib": { "version": "0.1008060.0", "resolved": "https://registry.npmjs.org/prebuilt-tdlib/-/prebuilt-tdlib-0.1008060.0.tgz", @@ -6982,6 +8852,37 @@ ], "license": "MIT" }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promise-retry/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -7037,13 +8938,23 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/pupa": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", @@ -7172,6 +9083,21 @@ "react": "^19.2.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -7442,6 +9368,16 @@ "license": "MIT", "peer": true }, + "node_modules/sdp-transform": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", + "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "license": "MIT", + "optional": true, + "bin": { + "sdp-verify": "checker.js" + } + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", @@ -7529,6 +9465,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -7536,15 +9479,15 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -7553,30 +9496,25 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" } }, "node_modules/shebang-command": { @@ -7691,6 +9629,62 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -7749,6 +9743,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/sonic-boom": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", @@ -7789,6 +9837,57 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ssri/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -7825,6 +9924,16 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -7999,6 +10108,16 @@ "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, + "node_modules/sync-promise-expanded": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/sync-promise-expanded/-/sync-promise-expanded-1.0.0.tgz", + "integrity": "sha512-pdxxEOaeKO6LghTz0Fe7yw82fx95gtS0SxVgRvIwvN4h9qTie8oOF/pWuH8PGp+PVduS84RXXxO/xrW93Nno9w==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -8012,6 +10131,75 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/tdl": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/tdl/-/tdl-8.0.2.tgz", @@ -8580,6 +10768,13 @@ "node": ">=20" } }, + "node_modules/tiny-queue": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tiny-queue/-/tiny-queue-0.2.1.tgz", + "integrity": "sha512-EijGsv7kzd9I9g0ByCl6h42BWNGUZrlCSejfrb3AKeHC33SGbASu1VDf5O3rRiiUOhAC9CHdZxFPbZu0HmR70A==", + "license": "Apache 2", + "optional": true + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -8720,6 +10915,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -8759,6 +10967,68 @@ "node": ">=14.17" } }, + "node_modules/typeson": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/typeson/-/typeson-9.0.4.tgz", + "integrity": "sha512-umRYLe37m4fTu0AlgFaqu1C+N+i3LqAehCzbvNx9w7alyN1wpPyS6FbBEsvWD2FppS2yJ9gk0FkFBkZ/wJE13Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/typeson-registry": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/typeson-registry/-/typeson-registry-12.0.0.tgz", + "integrity": "sha512-Yt53WwWPCiET6vH11gwkqeyv6g+NbVDCERzeTDklZZYaQi5EnjUcCSZnLEJbJKMRJaSFsqlrVg0NUD4g5aZoXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer-es6": "^3.1.0", + "typeson": "^9.0.4", + "whatwg-url": "^14.2.0" + }, + "engines": { + "node": "^20.11.0 || >= 22.0.0" + } + }, + "node_modules/typeson-registry/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typeson-registry/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/typeson-registry/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/uint8array-extras": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", @@ -8797,6 +11067,13 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "node_modules/unhomoglyph": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz", + "integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==", + "license": "MIT", + "optional": true + }, "node_modules/unicode-segmenter": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", @@ -8823,6 +11100,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -8952,6 +11249,27 @@ "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", "license": "BSD" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -9643,6 +11961,22 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/websql-configurable": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/websql-configurable/-/websql-configurable-3.0.3.tgz", + "integrity": "sha512-Fs+3A2BjI3ukkGtvsZcPS4nGoFE0aqors8YKWgICoOt7dcfdeaTdgoMOoFqlEPHc9u/TS9BEFhKP/A9GttB0CQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "argsarray": "^0.0.1", + "immediate": "^3.2.2", + "noop-fn": "^1.0.0", + "tiny-queue": "^0.2.1" + }, + "optionalDependencies": { + "sqlite3": "^5.0.2" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -9691,6 +12025,61 @@ "node": ">=8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wide-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/wide-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/widest-line": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", @@ -9882,6 +12271,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", diff --git a/package.json b/package.json index b9e4fa2..802797f 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "test": "vitest", "test:run": "vitest run --exclude 'e2e/**'", "lint:console": "bash scripts/no-console.sh", - "repro:context-window-reset": "tsx scripts/repro-context-window-reset.ts", "test:e2e": "vitest run e2e/", "skills": "tsx src/cli.ts skills", "skills:list": "tsx src/cli.ts skills list", @@ -35,7 +34,8 @@ "skill:search": "npx clawdhub search", "skill:list": "npx clawdhub list --dir ~/.letta/skills", "skills:add": "npx skills add --global --yes", - "skills:find": "npx skills find" + "skills:find": "npx skills find", + "repro:context-window-reset": "tsx scripts/repro-context-window-reset.ts" }, "keywords": [ "letta", @@ -66,7 +66,7 @@ "patches/" ], "dependencies": { - "@atproto/api": "^0.19.1", + "@atproto/api": "^0.19.3", "@clack/prompts": "^0.11.0", "@hapi/boom": "^10.0.1", "@letta-ai/letta-client": "^1.7.12", @@ -93,9 +93,17 @@ "yaml": "^2.8.2" }, "optionalDependencies": { + "@matrix-org/matrix-sdk-crypto-wasm": "^1.3.0", + "@matrix-org/olm": "^3.2.15", "@slack/bolt": "^4.6.0", + "@types/better-sqlite3": "^7.6.13", + "@types/matrix-js-sdk": "28.1.0", "@whiskeysockets/baileys": "6.7.21", + "better-sqlite3": "^11.10.0", "discord.js": "^14.25.1", + "fake-indexeddb": "^6.2.5", + "indexeddbshim": "^16.1.0", + "matrix-js-sdk": "^28.2.0", "prebuilt-tdlib": "^0.1008060.0", "slackify-markdown": "^5.0.0", "tdl": "^8.0.2" diff --git a/src/api/server.ts b/src/api/server.ts index 38f76db..ccc4f49 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -12,7 +12,7 @@ import type { SendMessageResponse, ChatRequest, ChatResponse, AsyncChatResponse, import { listPairingRequests, approvePairingCode } from '../pairing/store.js'; import { parseMultipart } from './multipart.js'; import type { AgentRouter } from '../core/interfaces.js'; -import type { ChannelId } from '../core/types.js'; +import type { ChannelId } from '../channels/setup.js'; import type { Store } from '../core/store.js'; import { generateCompletionId, extractLastUserMessage, buildCompletion, @@ -25,7 +25,7 @@ import { getTurnViewerHtml } from '../core/turn-viewer.js'; import { createLogger } from '../logger.js'; const log = createLogger('API'); -const VALID_CHANNELS: ChannelId[] = ['telegram', 'slack', 'discord', 'whatsapp', 'signal']; +const VALID_CHANNELS: ChannelId[] = ['telegram', 'slack', 'discord', 'whatsapp', 'signal', 'matrix']; const MAX_BODY_SIZE = 10 * 1024; // 10KB const MAX_TEXT_LENGTH = 10000; // 10k chars const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB diff --git a/src/channels/factory.ts b/src/channels/factory.ts index 053c185..a609ba2 100644 --- a/src/channels/factory.ts +++ b/src/channels/factory.ts @@ -1,5 +1,6 @@ import { BlueskyAdapter } from './bluesky.js'; import { DiscordAdapter } from './discord.js'; +import { MatrixAdapter } from './matrix/index.js'; import { SignalAdapter } from './signal.js'; import { SlackAdapter } from './slack.js'; import { TelegramMTProtoAdapter } from './telegram-mtproto.js'; @@ -188,6 +189,41 @@ export function createChannelsForAgent( } } + // Matrix: E2EE, TTS, STT, per-room routing + const matrixConfig = agentConfig.channels.matrix; + if (matrixConfig?.enabled !== false && matrixConfig?.homeserverUrl && matrixConfig?.userId) { + adapters.push(new MatrixAdapter({ + homeserverUrl: matrixConfig.homeserverUrl, + userId: matrixConfig.userId, + accessToken: matrixConfig.accessToken, + password: matrixConfig.password, + deviceId: matrixConfig.deviceId, + dmPolicy: matrixConfig.dmPolicy || 'pairing', + allowedUsers: nonEmpty(matrixConfig.allowedUsers), + selfChatMode: matrixConfig.selfChatMode ?? false, + enableEncryption: matrixConfig.enableEncryption ?? true, + recoveryKey: matrixConfig.recoveryKey, + userDeviceId: matrixConfig.userDeviceId, + storeDir: matrixConfig.storeDir || './data/matrix', + sessionDir: matrixConfig.sessionDir || matrixConfig.storeDir || './data/matrix', + autoJoinRooms: matrixConfig.autoJoinRooms ?? true, + // Group batching + groupDebounceSec: matrixConfig.groupDebounceSec, + instantGroups: nonEmpty(matrixConfig.instantGroups), + listeningGroups: nonEmpty(matrixConfig.listeningGroups), + // TTS/STT + transcriptionEnabled: matrixConfig.transcriptionEnabled ?? true, + sttUrl: matrixConfig.sttUrl, + ttsUrl: matrixConfig.ttsUrl, + ttsVoice: matrixConfig.ttsVoice, + enableAudioResponse: matrixConfig.enableAudioResponse ?? false, + audioRoomFilter: matrixConfig.audioRoomFilter || 'dm_only', + // Other features + attachmentsDir: sharedOptions.attachmentsDir, + attachmentsMaxBytes: sharedOptions.attachmentsMaxBytes, + })); + } + // Bluesky: only start if there's something to subscribe to if (agentConfig.channels.bluesky?.enabled) { const bsky = agentConfig.channels.bluesky; diff --git a/src/channels/matrix/adapter.ts b/src/channels/matrix/adapter.ts new file mode 100644 index 0000000..741c43e --- /dev/null +++ b/src/channels/matrix/adapter.ts @@ -0,0 +1,1685 @@ +/** + * Matrix Adapter - Main Implementation + */ + +import type { ChannelAdapter } from "../types.js"; +import type { InboundMessage, OutboundMessage, OutboundFile } from "../../core/types.js"; +import { createLogger } from "../../logger.js"; +import * as sdk from "matrix-js-sdk"; +import { RoomMemberEvent, RoomEvent, ClientEvent } from "matrix-js-sdk"; +import * as fs from "fs"; + +import { MatrixSessionManager } from "./session.js"; +import { initE2EE, getCryptoCallbacks, checkAndRestoreKeyBackup } from "./crypto.js"; +import { formatMatrixHTML } from "./html-formatter.js"; +import { handleTextMessage } from "./handlers/message.js"; +import { handleMembershipEvent } from "./handlers/invite.js"; +import { handleReactionEvent } from "./handlers/reaction.js"; +import { handleAudioMessage } from "./handlers/audio.js"; +import { handleImageMessage } from "./handlers/image.js"; +import { handleFileMessage } from "./handlers/file.js"; +import { MatrixCommandProcessor } from "./commands.js"; +// (pairing store used by handlers/message.ts directly) +import { synthesizeSpeech } from "./tts.js"; +import { MatrixVerificationHandler } from "./verification.js"; +type VerificationRequest = sdk.Crypto.VerificationRequest; + +import type { MatrixAdapterConfig } from "./types.js"; +import { DEFAULTS, SPECIAL_REACTIONS } from "./types.js"; +import { MsgType } from "matrix-js-sdk"; +import { MatrixStorage } from "./storage.js"; +import { resolveEmoji } from "../shared/emoji.js"; +import { buildAttachmentPath } from "../attachments.js"; +import { writeFileSync, mkdirSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { basename, extname } from "node:path"; + +// Content types for Matrix events (using any to avoid import issues) +type RoomMessageEventContent = any; +type ReactionEventContent = any; + +const log = createLogger('Matrix'); + +export class MatrixAdapter implements ChannelAdapter { + readonly id = "matrix" as const; + readonly name = "Matrix"; + + private config: Required> & { + password?: string; + accessToken?: string; + deviceId?: string; + recoveryKey?: string; + sttUrl?: string; + ttsUrl?: string; + messagePrefix?: string; + userDeviceId?: string; + attachmentsDir?: string; + attachmentsMaxBytes?: number; + uploadDir?: string; + }; + + private sessionManager: MatrixSessionManager; + private client: sdk.MatrixClient | null = null; + private deviceId: string | null = null; + private running = false; + private initialSyncDone = false; + private pendingImages: Map = new Map(); + private ourAudioEvents: Set = new Set(); + // Rooms waiting for a TTS response — set when a voice message is received, consumed in sendMessage + private pendingVoiceRooms: Set = new Set(); + private verificationHandler: MatrixVerificationHandler | null = null; + private pendingEncryptedEvents: Map = new Map(); + private storage: MatrixStorage; + private commandProcessor!: MatrixCommandProcessor; + private _heartbeatEnabled = true; + private _pruningTimer: NodeJS.Timeout | null = null; + + onMessage?: (msg: InboundMessage) => Promise; + onCommand?: (command: string, chatId?: string, args?: string) => Promise; + // Heartbeat toggle callbacks — wired by main.ts after heartbeatService is created + onHeartbeatStop?: () => void; + onHeartbeatStart?: () => void; + onTimeoutHeartbeat?: () => void; + getAgentId?: () => string | undefined; + // Invalidate the session for a conversation key — used by !new to force a fresh conversation + onInvalidateSession?: (key?: string) => void; + + constructor(config: MatrixAdapterConfig) { + if (!config.homeserverUrl) throw new Error("homeserverUrl is required"); + if (!config.userId) throw new Error("userId is required"); + if (!config.password && !config.accessToken) { + throw new Error("Either password or accessToken is required"); + } + + const storeDir = config.storeDir || config.sessionDir || "./data/matrix"; + this.config = { + homeserverUrl: config.homeserverUrl, + userId: config.userId, + accessToken: config.accessToken ?? undefined, + password: config.password ?? undefined, + deviceId: config.deviceId ?? undefined, + recoveryKey: config.recoveryKey ?? undefined, + dmPolicy: config.dmPolicy || "pairing", + allowedUsers: config.allowedUsers || [], + selfChatMode: config.selfChatMode !== false, + enableEncryption: config.enableEncryption !== false, + storeDir, + sessionFile: config.sessionFile || `${storeDir}/session.json`, + transcriptionEnabled: config.transcriptionEnabled !== false, + sttUrl: config.sttUrl ?? undefined, + ttsUrl: config.ttsUrl ?? undefined, + ttsVoice: config.ttsVoice || DEFAULTS.TTS_VOICE, + enableAudioResponse: config.enableAudioResponse || false, + audioRoomFilter: config.audioRoomFilter || DEFAULTS.AUDIO_ROOM_FILTER, + imageMaxSize: config.imageMaxSize || DEFAULTS.IMAGE_MAX_SIZE, + uploadDir: config.attachmentsDir ?? config.uploadDir ?? process.cwd(), + enableReactions: config.enableReactions !== false, + autoJoinRooms: config.autoJoinRooms !== false, + messagePrefix: config.messagePrefix ?? undefined, + userDeviceId: config.userDeviceId ?? undefined, + enableStoragePruning: config.enableStoragePruning !== false, + storageRetentionDays: config.storageRetentionDays ?? 30, + storagePruningIntervalHours: config.storagePruningIntervalHours ?? 24, + sessionDir: config.sessionDir ?? storeDir, + streaming: config.streaming !== false, + groupDebounceSec: config.groupDebounceSec ?? 5, + instantGroups: config.instantGroups ?? [], + listeningGroups: config.listeningGroups ?? [], + }; + + this.sessionManager = new MatrixSessionManager({ sessionFile: this.config.sessionFile }); + this.storage = new MatrixStorage({ dataDir: storeDir }); + + log.info(`Adapter initialized for ${config.userId}`); + } + + async start(): Promise { + if (this.running) return; + + log.info("Starting adapter..."); + await this.storage.init(); + + // Instantiate command processor (after storage is ready) + this.commandProcessor = new MatrixCommandProcessor(this.storage, { + onHeartbeatStop: () => { this._heartbeatEnabled = false; this.onHeartbeatStop?.(); }, + onHeartbeatStart: () => { this._heartbeatEnabled = true; this.onHeartbeatStart?.(); }, + isHeartbeatEnabled: () => this._heartbeatEnabled, + onTimeoutHeartbeat: () => this.onTimeoutHeartbeat?.(), + getAgentId: () => this.getAgentId?.(), + onInvalidateSession: (key: string) => this.onInvalidateSession?.(key), + }); + + await this.initClient(); + this.setupEventHandlers(); + await this.startSync(); + this.startPeriodicPruning(); + + this.running = true; + log.info("Adapter started successfully"); + } + + async stop(): Promise { + if (!this.running) return; + + this.stopPeriodicPruning(); + + if (this.client) { + await this.client.stopClient(); + this.client = null; + } + + this.running = false; + log.info("Adapter stopped"); + } + + // ─── Storage Pruning ─────────────────────────────────────────────────────── + + /** + * Start periodic storage pruning + */ + private startPeriodicPruning(): void { + if (!this.config.enableStoragePruning) { + log.info('Storage pruning disabled by config'); + return; + } + + const intervalMs = this.config.storagePruningIntervalHours * 60 * 60 * 1000; + log.info(`Starting periodic storage pruning (every ${this.config.storagePruningIntervalHours}h, retention ${this.config.storageRetentionDays} days)`); + + // Run immediately on startup + this.runStoragePruning(); + + // Then periodically + this._pruningTimer = setInterval(() => { + this.runStoragePruning(); + }, intervalMs); + } + + /** + * Stop periodic storage pruning + */ + private stopPeriodicPruning(): void { + if (this._pruningTimer) { + clearInterval(this._pruningTimer); + this._pruningTimer = null; + log.info('Stopped periodic storage pruning'); + } + } + + /** + * Run storage pruning + */ + private runStoragePruning(): void { + try { + const results = this.storage.pruneOldEntries(this.config.storageRetentionDays); + + // Log stats + if (results && results.some(r => r.deletedCount > 0)) { + const stats = this.storage.getPruningStats(); + log.info(`Storage stats: audio_messages=${stats.audioMessagesCount}, message_mappings=${stats.messageMappingsCount}`); + } + } catch (err) { + log.error('Storage pruning error:', err); + } + } + + isRunning(): boolean { + return this.running; + } + + async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> { + if (!this.client) throw new Error("Matrix client not initialized"); + + const { chatId, text } = msg; + const { plain, html } = formatMatrixHTML(text); + const htmlBody = (msg.htmlPrefix || '') + html; + + const content = { + msgtype: MsgType.Text, + body: this.config.messagePrefix ? `${this.config.messagePrefix}\n\n${plain}` : plain, + format: "org.matrix.custom.html", + formatted_body: this.config.messagePrefix ? `${this.config.messagePrefix}

${htmlBody}` : htmlBody, + } as RoomMessageEventContent; + + const response = await this.client.sendMessage(chatId, content); + const eventId = response.event_id; + + // Send TTS audio if this was a voice-input response or enableAudioResponse is set + if (this.config.ttsUrl && this.shouldSendAudio(chatId)) { + this.sendAudio(chatId, plain).catch(err => log.error('TTS failed (non-fatal):', err)); + } + + // Add 🎤 reaction so user can request TTS on demand + if (this.config.ttsUrl) { + this.addReaction(chatId, eventId, '🎤').catch(() => {}); + } + + return { messageId: eventId }; + } + + /** + * Decide whether to send a TTS audio response for this room. + * Consumes the pendingVoiceRooms flag if set (voice-input path). + */ + private shouldSendAudio(chatId: string): boolean { + // Voice-input path: always respond with audio regardless of audioRoomFilter + if (this.pendingVoiceRooms.has(chatId)) { + this.pendingVoiceRooms.delete(chatId); + return true; + } + // Auto-TTS path: respect enableAudioResponse + audioRoomFilter + if (!this.config.enableAudioResponse) return false; + if (this.config.audioRoomFilter === 'none') return false; + if (this.config.audioRoomFilter === 'dm_only') { + const room = this.client?.getRoom(chatId); + return room ? room.getJoinedMembers().length === 2 : false; + } + return true; // 'all' + } + + async editMessage(chatId: string, messageId: string, text: string, htmlPrefix?: string): Promise { + if (!this.client) throw new Error("Matrix client not initialized"); + + const { plain, html } = formatMatrixHTML(text); + const htmlBody = (htmlPrefix || '') + html; + const prefixedPlain = this.config.messagePrefix ? `${this.config.messagePrefix}\n\n${plain}` : plain; + const prefixedHtml = this.config.messagePrefix ? `${this.config.messagePrefix}

${htmlBody}` : htmlBody; + + const editContent = { + msgtype: MsgType.Text, + body: `* ${prefixedPlain}`, + format: "org.matrix.custom.html", + formatted_body: prefixedHtml, + "m.new_content": { + msgtype: MsgType.Text, + body: prefixedPlain, + format: "org.matrix.custom.html", + formatted_body: prefixedHtml, + }, + "m.relates_to": { + rel_type: sdk.RelationType.Replace, + event_id: messageId, + }, + } as RoomMessageEventContent; + + await this.client.sendMessage(chatId, editContent); + } + + supportsEditing(): boolean { + return this.config.streaming !== false; // Respect streaming config — false disables live edit updates + } + + getDmPolicy(): string { + return this.config.dmPolicy; + } + + getFormatterHints(): import('../../core/types.js').FormatterHints { + return { + supportsReactions: true, + supportsFiles: true, + formatHint: 'Matrix HTML: **bold** _italic_ `code` ```code blocks``` — supports color {color|text}, spoilers ||text||, and @mentions', + }; + } + + async sendTypingIndicator(chatId: string): Promise { + if (!this.client) return; + try { + await this.client.sendTyping(chatId, true, 5000); + } catch (err) { + log.warn("Failed to send typing indicator:", err); + } + } + + async stopTypingIndicator(chatId: string): Promise { + if (!this.client) return; + try { + await this.client.sendTyping(chatId, false, 0); + } catch { + // best-effort, ignore errors + } + } + + private async initClient(): Promise { + log.info("Initializing client..."); + + const baseUrl = this.config.homeserverUrl; + let session = this.sessionManager.loadSession(); + + // If password is available, always do fresh login (delete old devices, create new one) + if (this.config.password) { + log.info('Password available, performing fresh login (deleting old devices)...'); + + // Clear old session first since we're doing fresh login + if (session) { + log.info('Clearing old session file for fresh login...'); + try { + fs.unlinkSync(this.config.sessionFile); + log.info('Old session cleared'); + } catch (e) { + // File might not exist + } + session = null; // Ensure we don't use old session data + } + + const loginClient = sdk.createClient({ baseUrl: baseUrl }); + + // Single login — creates one new device for this session + const response = await loginClient.loginWithPassword(this.config.userId, this.config.password); + + this.client = sdk.createClient({ + baseUrl: baseUrl, + userId: response.user_id, + accessToken: response.access_token, + deviceId: response.device_id ?? this.config.deviceId ?? undefined, + cryptoCallbacks: this.config.recoveryKey ? getCryptoCallbacks(this.config.recoveryKey) : undefined, + }); + + this.deviceId = response.device_id || this.config.deviceId || null; + log.info(`Fresh login complete (new device: ${this.deviceId})`); + + // Aggressive device cleanup (mirrors restart.sh logic): + // Delete ALL devices except our new device and the user's Element session. + // This catches ANI_*, lettabot*, and any other legacy/orphaned bot devices. + try { + const devices = await this.client.getDevices(); + const devicesToDelete = devices?.devices?.filter((d: any) => { + // Keep our current device + if (d.device_id === response.device_id) return false; + // Keep the user's Element/Firefox session + if (this.config.userDeviceId && d.device_id === this.config.userDeviceId) return false; + // Delete everything else + return true; + }) || []; + if (devicesToDelete.length > 0) { + log.info(`Cleaning up ${devicesToDelete.length} old device(s): ${devicesToDelete.map((d: any) => d.device_id).join(', ')}`); + for (const device of devicesToDelete) { + try { + await this.client.deleteDevice(device.device_id, { + type: 'm.login.password', + user: this.config.userId, + password: this.config.password, + }); + log.info(`✓ Deleted device: ${device.device_id}`); + } catch (err) { + log.warn(`Failed to delete device ${device.device_id}: ${err}`); + } + } + } else { + log.info('No old devices to clean up'); + } + } catch (err) { + log.warn(`Unable to fetch device list for cleanup: ${err}`); + } + + this.sessionManager.saveSession({ + userId: response.user_id, + deviceId: this.deviceId!, + accessToken: response.access_token, + homeserver: this.config.homeserverUrl, + timestamp: new Date().toISOString(), + }); + } else if (session?.accessToken) { + // No password, try to restore existing session + this.client = sdk.createClient({ + baseUrl: baseUrl, + userId: session.userId, + accessToken: session.accessToken, + deviceId: session.deviceId ?? this.config.deviceId ?? undefined, + cryptoCallbacks: this.config.recoveryKey ? getCryptoCallbacks(this.config.recoveryKey) : undefined, + }); + this.deviceId = session.deviceId || this.config.deviceId || null; + log.info(`Session restored (device: ${this.deviceId})`); + } else { + throw new Error("Either accessToken or password is required"); + } + + // Export Matrix credentials to env so lettabot-message CLI (used by agent + // via Bash during heartbeat) can send messages without separate config. + const clientAccessToken = this.client.getAccessToken(); + if (clientAccessToken) { + process.env.MATRIX_ACCESS_TOKEN = clientAccessToken; + process.env.MATRIX_HOMESERVER_URL = baseUrl; + log.info('Exported MATRIX_ACCESS_TOKEN and MATRIX_HOMESERVER_URL to env'); + } + + // Initialize built-in E2EE + if (this.config.enableEncryption) { + await initE2EE(this.client, { + enableEncryption: true, + recoveryKey: this.config.recoveryKey, + storeDir: this.config.storeDir, + password: this.config.password, + userId: this.config.userId, + }); + + // Register callback for when room keys are updated (received from other devices) + const crypto = this.client.getCrypto(); + if (crypto && (crypto as any).registerRoomKeyUpdatedCallback) { + (crypto as any).registerRoomKeyUpdatedCallback(() => { + log.info("Room keys updated, retrying pending decryptions..."); + this.retryPendingDecryptions(); + }); + } + + // Restore keys from backup if recovery key is available + if (this.config.recoveryKey) { + log.info('Recovery key available, checking key backup...'); + await checkAndRestoreKeyBackup(this.client, this.config.recoveryKey); + } + } + } + + /** + * Retry decrypting pending encrypted events after receiving new keys + */ + private async retryPendingDecryptions(): Promise { + if (!this.client || this.pendingEncryptedEvents.size === 0) return; + + log.info(`Retrying ${this.pendingEncryptedEvents.size} pending decryptions...`); + const eventsToRetry = new Map(this.pendingEncryptedEvents); + this.pendingEncryptedEvents.clear(); + + for (const [eventId, event] of Array.from(eventsToRetry.entries())) { + try { + // Try to get decrypted content now + const clearContent = event.getClearContent(); + if (clearContent) { + log.info(`Successfully decrypted event ${eventId} after key arrival`); + // Process as room message + const room = this.client.getRoom(event.getRoomId()!); + if (room) { + await this.handleMessageEvent(event, room); + } + } else { + // Still can't decrypt, put back in queue + this.pendingEncryptedEvents.set(eventId, event); + } + } catch (err) { + log.warn(`Failed to retry decryption for ${eventId}:`, err); + // Put back in queue for next retry + this.pendingEncryptedEvents.set(eventId, event); + } + } + + // Clean up old events (keep for 5 minutes max) + const now = Date.now(); + const maxAge = 5 * 60 * 1000; + for (const [eventId, event] of Array.from(this.pendingEncryptedEvents.entries())) { + const eventTime = event.getTs(); + if (now - eventTime > maxAge) { + this.pendingEncryptedEvents.delete(eventId); + log.info(`Dropped old pending event ${eventId}`); + } + } + } + + private setupEventHandlers(): void { + if (!this.client) return; + + this.client.on(RoomMemberEvent.Membership, (event: any, member: any) => { + if (!this.initialSyncDone) return; + if (this.config.autoJoinRooms) { + handleMembershipEvent({ + client: this.client!, + event, + member, + dmPolicy: this.config.dmPolicy, + allowedUsers: this.config.allowedUsers, + autoAccept: true, + ourUserId: this.client?.getUserId() ?? undefined, + }).catch((err) => log.error("Unhandled error:", err)); + } + }); + + this.client.on(RoomEvent.Timeline, async (event: any, room: any, toStartOfTimeline: any) => { + let eventType = event.getType(); + + // Always process encrypted events to request keys if needed + // Other events can be skipped during initial sync + if (eventType !== 'm.room.encrypted' && (toStartOfTimeline || !this.initialSyncDone)) { + log.debug(`Timeline event skipped: toStartOfTimeline=${toStartOfTimeline}, initialSyncDone=${this.initialSyncDone}`); + return; + } + if (event.getSender() === this.client?.getUserId()) { + log.debug(`Timeline event skipped: own message`); + return; + } + if (!room) { + log.debug(`Timeline event skipped: no room`); + return; + } + + log.debug(`Timeline event: type=${eventType}, sender=${event.getSender()}, room=${room.roomId}`); + + // Handle encrypted events - check if SDK has decrypted them + if (eventType === 'm.room.encrypted') { + log.debug(`Encrypted event received, checking for decrypted content...`); + + // Try to get decrypted content + let clearContent; + try { + clearContent = event.getClearContent(); + } catch (err) { + log.warn(`getClearContent failed (crypto transaction inactive during initial sync):`, err instanceof Error ? err.message : String(err)); + // Queue for later processing when crypto is ready + event.once("Event.decrypted" as any, async (decryptedEvent: typeof event) => { + let retryClearContent; + try { + retryClearContent = decryptedEvent.getClearContent(); + } catch (retryErr) { + log.warn(`getClearContent failed on decrypted event:`, retryErr instanceof Error ? retryErr.message : String(retryErr)); + return; + } + if (retryClearContent) { + log.info(`Event ${decryptedEvent.getId()} decrypted after crypto ready!`); + // Process the now-decrypted event + const decryptedRoom = this.client?.getRoom(decryptedEvent.getRoomId()!); + if (decryptedRoom) { + await this.handleMessageEvent(decryptedEvent, decryptedRoom); + } + } + }); + this.requestRoomKey(event).catch((err) => { + log.warn("Failed to request room key:", err instanceof Error ? err.message : String(err)); + }); + return; + } + + if (clearContent) { + // SDK has decrypted this event - get the actual event type from the decrypted content + // We need to check if there's a msgtype to determine what kind of message this is + const msgtype = (clearContent as any).msgtype; + log.debug(`Event decrypted by SDK, msgtype=${msgtype}`); + + // Treat decrypted events as room messages for processing + // The actual content will be extracted in handleMessageEvent + eventType = sdk.EventType.RoomMessage; + } else { + log.debug(`SDK couldn't decrypt event yet, waiting for keys...`); + // Listen for when this specific event gets decrypted + event.once("Event.decrypted" as any, async (decryptedEvent: typeof event) => { + let decryptedClearContent; + try { + decryptedClearContent = decryptedEvent.getClearContent(); + } catch (retryErr) { + log.warn(`getClearContent failed on decrypted event:`, retryErr instanceof Error ? retryErr.message : String(retryErr)); + return; + } + if (decryptedClearContent) { + log.info(`Event ${decryptedEvent.getId()} decrypted after key arrival!`); + // Process the now-decrypted event + const decryptedRoom = this.client?.getRoom(decryptedEvent.getRoomId()!); + if (decryptedRoom) { + await this.handleMessageEvent(decryptedEvent, decryptedRoom); + } + } + }); + // Request keys from other devices + this.requestRoomKey(event).catch((err) => { + log.warn("Failed to request room key:", err instanceof Error ? err.message : String(err)); + }); + return; // Skip immediate processing - will handle when Event.decrypted fires + } + } + + try { + // Handle verification requests that come through room timeline + if (eventType === 'm.key.verification.request') { + log.debug(`Verification request received in room timeline from ${event.getSender()}`); + return; // Don't process as regular message - verification handler will handle it + } + + // Handle room key events - these are crucial for decryption + if (eventType === 'm.room_key' || eventType === 'm.forwarded_room_key') { + const keyContent = event.getContent(); + log.debug(`Room key received from ${event.getSender()}:`); + log.debug(` Room: ${keyContent.room_id}`); + log.debug(` Session: ${keyContent.session_id}`); + log.debug(` Algorithm: ${keyContent.algorithm}`); + log.debug(` Sender Key: ${keyContent.sender_key?.substring(0, 16)}...`); + // Retry any pending decryptions now that we have new keys + this.retryPendingDecryptions(); + return; + } + + switch (eventType) { + case sdk.EventType.RoomMessage: + await this.handleMessageEvent(event, room); + break; + case sdk.EventType.Reaction: + await handleReactionEvent({ + client: this.client!, + event, + ourUserId: this.client!.getUserId()!, + storage: this.storage, + sendMessage: async (roomId, text) => { + await this.sendMessage({ chatId: roomId, text }); + }, + regenerateTTS: async (text, roomId) => { + await this.regenerateTTS(text, roomId); + }, + forwardToLetta: async (text, roomId, sender) => { + if (this.onMessage) { + await this.onMessage({ + channel: 'matrix', + chatId: roomId, + userId: sender, + text, + timestamp: new Date(), + }); + } + }, + sendPendingImageToAgent: (targetEventId, roomId, sender) => { + // Check if this reaction targets a pending image (by eventId) + if (!this.pendingImages.has(targetEventId)) return false; + + // Get the pending image and attach it to the synthetic message + const pendingImage = this.getPendingImage(roomId); + if (!pendingImage) return false; + + const isDm = room.getJoinedMembers().length === 2; + setImmediate(() => { + const synthetic: InboundMessage = { + channel: 'matrix', + chatId: roomId, + userId: sender, + userName: room.getMember(sender)?.name || sender, + userHandle: sender, + text: '[Image]', + timestamp: new Date(), + isGroup: !isDm, + groupName: isDm ? undefined : (room.name || roomId), + }; + // Save image to disk for upstream compatibility + const uploadDir = this.config.uploadDir ?? process.cwd(); + const filename = `image-${Date.now()}.${pendingImage.format}`; + const localPath = buildAttachmentPath(uploadDir, 'matrix', roomId, filename); + try { + mkdirSync(localPath.substring(0, localPath.lastIndexOf('/')), { recursive: true }); + writeFileSync(localPath, pendingImage.imageData); + synthetic.attachments = [{ + kind: 'image', + mimeType: `image/${pendingImage.format}`, + localPath, + }]; + } catch (saveErr) { + log.error(`Failed to save image to ${localPath}:`, saveErr); + } + this.onMessage?.(this.enrichWithConversation(synthetic, room)); + }); + return true; + }, + }); + break; + } + } catch (err) { + log.error("Error handling event:", err); + } + }); + + this.client.on(ClientEvent.Sync, (state: any) => { + log.info(`Sync state: ${state}`); + if (state === "PREPARED" || state === "SYNCING") { + if (!this.initialSyncDone) { + this.initialSyncDone = true; + log.info("Initial sync complete"); + // Run post-sync setup in background (non-blocking) + this.runPostSyncSetup().catch((err) => { + log.error("Post-sync setup failed:", err); + }); + } + } + }); + } + + private setupVerificationHandler(): void { + if (!this.client) return; + + this.verificationHandler = new MatrixVerificationHandler(this.client, { + onShowSas: (emojis) => { + log.info(`*** EMOJI VERIFICATION ***`); + log.info(`${emojis.join(" | ")}`); + }, + onComplete: () => { + log.info(`*** VERIFICATION COMPLETE! ***`); + }, + onCancel: (reason) => { + log.info(`*** VERIFICATION CANCELLED: ${reason} ***`); + }, + onError: (err) => { + log.error(`Verification error:`, err); + }, + }); + + // CRITICAL: Setup event handlers for verification + // This MUST be called before client.startClient() + this.verificationHandler.setupEventHandlers(); + } + + /** + * Auto-trust all devices for this user (similar to Python's TrustState.UNVERIFIED) + * This allows the bot to decrypt messages without interactive verification + */ + private async runPostSyncSetup(): Promise { + log.info("Running post-sync setup..."); + try { + // Auto-trust all devices for this user + await this.autoTrustDevices(); + } catch (err) { + log.error("autoTrustDevices failed:", err); + } + try { + // Check if backup exists before attempting restore (prevents uncaught errors) + if (this.config.recoveryKey && this.client) { + const crypto = this.client.getCrypto(); + if (crypto) { + try { + const backupVersion = await this.client.getKeyBackupVersion(); + if (backupVersion) { + log.info("Key backup found on server, attempting restore..."); + await this.restoreKeysFromBackup(); + } else { + log.info("No key backup on server, skipping restore"); + } + } catch (backupErr: any) { + if (backupErr.errcode === 'M_NOT_FOUND' || backupErr.httpStatus === 404) { + log.info("Key backup not found on server, skipping restore"); + } else { + log.warn("Error checking key backup:", backupErr); + } + } + } + } + } catch (err) { + log.error("restoreKeysFromBackup failed:", err); + } + try { + // Import room keys from file if available + await this.importRoomKeysFromFile(); + } catch (err) { + log.error("importRoomKeysFromFile failed:", err); + } + try { + // Initiate proactive verification + await this.initiateProactiveVerification(); + } catch (err) { + log.error("initiateProactiveVerification failed:", err); + } + log.info("Post-sync setup complete"); + } + + private async autoTrustDevices(): Promise { + if (!this.client) return; + const crypto = this.client.getCrypto(); + if (!crypto) return; + + const userId = this.client.getUserId(); + if (!userId) return; + + try { + log.info("Auto-trusting devices for", userId); + + // Get all devices for this user + const devices = await crypto.getUserDeviceInfo([userId]); + const userDevices = devices.get(userId); + + if (!userDevices) { + log.info("No devices found for user"); + return; + } + + for (const [deviceId, deviceInfo] of Array.from(userDevices.entries())) { + if (deviceId === this.client.getDeviceId()) { + // Skip our own device + continue; + } + + // Check if already verified + const status = await crypto.getDeviceVerificationStatus(userId, deviceId); + if (!status?.isVerified()) { + log.info(`Marking device ${deviceId} as verified`); + await crypto.setDeviceVerified(userId, deviceId, true); + } + } + + log.info("Device trust setup complete"); + } catch (err) { + log.error("Failed to auto-trust devices:", err); + } + } + + /** + * Import room keys from exported file + * This allows decryption of messages from Element export + */ + private async importRoomKeysFromFile(): Promise { + if (!this.client) return; + + const fs = await import('fs'); + const path = await import('path'); + + // Check for pre-decrypted keys first (from import-casey-keys.ts) + const storeDir = path.resolve(this.config.storeDir || './data/matrix'); + const decryptedKeysFile = path.join(storeDir, 'imported-keys.json'); + + if (fs.existsSync(decryptedKeysFile)) { + log.info("Found pre-decrypted keys at", decryptedKeysFile); + try { + const keysData = fs.readFileSync(decryptedKeysFile, 'utf8'); + const keys = JSON.parse(keysData); + log.info(`Importing ${keys.length} pre-decrypted room keys...`); + + const crypto = this.client.getCrypto(); + if (crypto) { + await crypto.importRoomKeys(keys); + log.info("✓ Room keys imported successfully!"); + // Rename file to indicate it's been imported + fs.renameSync(decryptedKeysFile, decryptedKeysFile + '.imported'); + return; + } + } catch (err) { + log.warn("Failed to import pre-decrypted keys:", err); + } + } + + log.info("No pre-decrypted key file found"); + } + + /** + * Decrypt Megolm export file using recovery key + */ + private async decryptMegolmExport(data: Buffer, key: Uint8Array): Promise { + // Element exports use a specific format: + // 1. Base64 encoded data + // 2. Encrypted with AES-GCM + // 3. Key derived from recovery key + + // Extract base64 content + const content = data.toString('utf8'); + const lines = content.trim().split('\n'); + const base64Data = lines.slice(1, -1).join(''); // Remove BEGIN/END markers + + // Decode base64 + const encrypted = Buffer.from(base64Data, 'base64'); + + // For now, just return as-is and let the SDK handle it + // The SDK's importRoomKeys may handle the decryption + return encrypted.toString('utf8'); + } + + /** + * Restore room keys from backup after sync completes + * This is needed to decrypt historical messages + */ + private async restoreKeysFromBackup(): Promise { + if (!this.client || !this.config.recoveryKey) return; + + const crypto = this.client.getCrypto(); + if (!crypto) return; + + log.info("Checking key backup after sync..."); + try { + // Get backup info without requiring it to be trusted + const { decodeRecoveryKey } = await import("matrix-js-sdk/lib/crypto/recoverykey.js"); + const backupKey = decodeRecoveryKey(this.config.recoveryKey); + + // First, try to enable backup by storing the key + try { + await crypto.storeSessionBackupPrivateKey(backupKey); + log.info("Backup key stored in session"); + } catch (e) { + // Key might already be stored + } + + // Check backup info + try { + const backupInfo = await crypto.checkKeyBackupAndEnable(); + if (backupInfo) { + log.info("Key backup info retrieved, attempting restore..."); + try { + const result = await (this.client as any).restoreKeyBackup( + backupKey, + undefined, // all rooms + undefined, // all sessions + backupInfo.backupInfo, + ); + log.info(`Restored ${result.imported} keys from backup`); + // Retry pending decryptions with newly restored keys + if (result.imported > 0) { + log.info("Retrying pending decryptions after backup restore..."); + await this.retryPendingDecryptions(); + } + } catch (restoreErr: any) { + log.warn("Failed to restore keys from backup:", restoreErr.message || restoreErr); + log.info("Will try to get keys from other devices via key sharing"); + } + } else { + log.info("No trusted key backup available - will rely on key sharing from verified devices"); + } + } catch (backupCheckErr: any) { + log.warn("Key backup check failed (this is expected with a new device):", backupCheckErr.message || backupCheckErr); + } + + // CRITICAL: Wait a bit for sync to complete before proceeding + // This allows verification to work properly + await new Promise(resolve => setTimeout(resolve, 2000)); + } catch (err: any) { + log.warn("Key backup check failed:", err?.message || err); + } + } + + /** + * Request room key from other devices when decryption fails + */ + private async requestRoomKey(event: sdk.MatrixEvent): Promise { + if (!this.client) return; + + const content = event.getContent(); + const sender = event.getSender(); + const roomId = event.getRoomId(); + + if (!content?.sender_key || !content?.session_id || !roomId) { + log.debug(`Cannot request key: missing sender_key, session_id, or roomId`); + return; + } + + log.info(`Requesting room key:`); + log.info(` Room: ${roomId}`); + log.info(` Session: ${content.session_id}`); + log.info(` Sender Key: ${content.sender_key?.substring(0, 16)}...`); + log.info(` Algorithm: ${content.algorithm}`); + log.info(` From user: ${sender}`); + + try { + // Use the legacy crypto's requestRoomKey via the client + // This sends m.room_key_request to other devices + await (this.client as any).requestRoomKey({ + room_id: roomId, + sender_key: content.sender_key, + session_id: content.session_id, + algorithm: content.algorithm, + }, [ + { userId: sender!, deviceId: '*' } // Request from all devices of the sender + ]); + log.info(`Room key request sent successfully`); + } catch (err) { + // requestRoomKey might not exist in rust crypto, that's ok + log.info(`Room key request not supported or failed (this is expected with rust crypto)`); + } + } + + /** + * Request verification with a specific device + * Useful for proactive verification + */ + async requestDeviceVerification(userId: string, deviceId: string): Promise { + if (!this.verificationHandler) { + throw new Error("Verification handler not initialized"); + } + + log.info(`Requesting verification with ${userId}:${deviceId}`); + return this.verificationHandler.requestVerification(userId, deviceId); + } + + /** + * Get current verification requests for a user + */ + getVerificationRequests(userId: string): VerificationRequest[] { + if (!this.verificationHandler) return []; + return this.verificationHandler.getVerificationRequests(userId); + } + + /** + * Proactively initiate verification with user devices + * This triggers Element to show the emoji verification UI + */ + private async initiateProactiveVerification(): Promise { + if (!this.client || !this.verificationHandler) return; + const crypto = this.client.getCrypto(); + if (!crypto) return; + + const userId = this.client.getUserId(); + if (!userId) return; + + const ownDeviceId = this.client.getDeviceId(); + + try { + log.info(`*** INITIATING PROACTIVE VERIFICATION ***`); + + // If userDeviceId is configured, send verification request directly to it + if (this.config.userDeviceId && this.config.userDeviceId.trim()) { + const targetDeviceId = this.config.userDeviceId.trim(); + + if (targetDeviceId === ownDeviceId) { + log.info(`userDeviceId (${targetDeviceId}) is the same as bot's device ID - skipping`); + return; + } + + log.info(`Using configured userDeviceId: ${targetDeviceId}`); + + try { + log.info(`*** REQUESTING VERIFICATION with user device ${targetDeviceId} ***`); + await this.requestDeviceVerification(userId, targetDeviceId); + log.info(`✓ Verification request sent to ${targetDeviceId}`); + log.info(`*** Check Element - the emoji verification UI should now appear! ***`); + return; // Done - targeted device verified successfully + } catch (err) { + log.error(`Failed to request verification with configured device ${targetDeviceId}:`, err); + log.info(`Falling back to automatic device discovery...`); + } + // Fall through to auto-discovery if direct request fails + } + + // The device list query is async and may not be complete yet + // Retry a few times with delays to get the full device list + let userDevices: Map | undefined; + let retryCount = 0; + const maxRetries = 5; + + while (retryCount < maxRetries) { + log.info(`Fetching device list (attempt ${retryCount + 1}/${maxRetries})...`); + + const devices = await crypto.getUserDeviceInfo([userId]); + userDevices = devices.get(userId); + + if (!userDevices || userDevices.size === 0) { + log.info(`No devices found for user ${userId}, retrying...`); + await new Promise((resolve) => setTimeout(resolve, 3000)); + retryCount++; + } else { + log.info(`Found ${userDevices.size} device(s) for user ${userId}`); + // Log all device IDs + for (const [deviceId] of Array.from(userDevices.entries())) { + log.info(` - Device: ${deviceId}`); + } + break; + } + } + + if (!userDevices || userDevices.size === 0) { + log.info(`No devices found for user ${userId} after ${maxRetries} attempts`); + return; + } + + let initiatedCount = 0; + + // Request verification with each of the user's other devices (not the bot's device) + for (const [deviceId, deviceInfo] of Array.from(userDevices.entries())) { + // Skip our own device + if (deviceId === ownDeviceId) { + log.info(`Skipping own device ${deviceId}`); + continue; + } + + log.info(`Checking device ${deviceId} for verification...`); + log.info(`Device info:`, JSON.stringify(deviceInfo)); // Debug logging + + // Check if this device is already verified from our perspective + const status = await crypto.getDeviceVerificationStatus(userId, deviceId); + log.info(`Device ${deviceId} verification status:`, { + isVerified: status?.isVerified(), + localVerified: status?.localVerified, + crossSigningVerified: status?.crossSigningVerified, + }); + + if (status && status.isVerified()) { + log.info(`Device ${deviceId} is already verified`); + continue; + } + + log.info(`*** REQUESTING VERIFICATION with user device ${deviceId} ***`); + try { + await this.requestDeviceVerification(userId, deviceId); + initiatedCount++; + log.info(`✓ Verification request sent to ${deviceId}`); + } catch (err) { + log.warn(`Failed to request verification with ${deviceId}:`, err); + } + } + + if (initiatedCount > 0) { + log.info(`✓ Successfully initiated ${initiatedCount} verification request(s)`); + log.info(`*** Check Element - the emoji verification UI should now appear! ***`); + } else { + log.info(`No new verification requests initiated (all devices may be verified)`); + } + } catch (err) { + log.error(`Failed to initiate proactive verification:`, err); + } + } + + private async handleMessageEvent(event: sdk.MatrixEvent, room: sdk.Room): Promise { + // For encrypted events, use clear content if available + let content; + try { + content = event.getClearContent() || event.getContent(); + } catch (err) { + // If crypto transaction is inactive, fall back to encrypted content + log.warn(`Failed to get clear content, using encrypted content:`, err instanceof Error ? err.message : String(err)); + content = event.getContent(); + } + const msgtype = content?.msgtype; + const ourUserId = this.client!.getUserId(); + log.debug(`handleMessageEvent: msgtype=${msgtype}, ourUserId=${ourUserId}`); + if (!ourUserId) return; + + if (msgtype === "m.text" || msgtype === "m.notice") { + log.debug(`Processing text message from ${event.getSender()}: ${content.body?.substring(0, 50)}`); + const result = await handleTextMessage({ + client: this.client!, + room, + event, + ourUserId, + config: { + selfChatMode: this.config.selfChatMode, + dmPolicy: this.config.dmPolicy, + allowedUsers: this.config.allowedUsers, + }, + sendMessage: async (roomId, text) => { + await this.sendMessage({ chatId: roomId, text }); + }, + onCommand: this.onCommand, + commandProcessor: this.commandProcessor, + }); + + if (result) { + // Check for pending image to attach + const pendingImage = this.getPendingImage(result.chatId); + if (pendingImage) { + log.debug(`Attaching pending image (${pendingImage.format}, ${pendingImage.imageData.length} bytes) to text message`); + // Save image to disk for upstream compatibility + const uploadDir = this.config.uploadDir ?? process.cwd(); + const filename = `image-${Date.now()}.${pendingImage.format}`; + const localPath = buildAttachmentPath(uploadDir, 'matrix', result.chatId, filename); + log.debug(`Writing image: ${localPath}, size=${pendingImage.imageData.length} bytes, format=${pendingImage.format}`); + + try { + // Ensure directory exists + const dirPath = localPath.substring(0, localPath.lastIndexOf('/')); + mkdirSync(dirPath, { recursive: true }); + + // Write image data - this MUST succeed for image handling to work + writeFileSync(localPath, pendingImage.imageData); + + // Verify the file was written + const fs = await import('node:fs'); + let stats = null; + try { + stats = fs.statSync(localPath); + } catch { + // statSync failed, stats stays null + } + if (!stats) { + log.error(`CRITICAL: File write verification failed for ${localPath}`); + } else { + log.debug(`Image written successfully: ${localPath}, size=${stats.size} bytes`); + } + + result.attachments = [{ + kind: 'image', + mimeType: `image/${pendingImage.format}`, + localPath, + }]; + log.debug(`Image attached to message`); + } catch (saveErr) { + // Log detailed error for debugging (NOT silent - this means image is lost) + log.error(`Failed to save image to ${localPath}:`, saveErr); + log.error(`Error details:`, { + error: saveErr instanceof Error ? saveErr.message : String(saveErr), + localPath, + imageDataLength: pendingImage?.imageData?.length ?? 0, + format: pendingImage?.format ?? 'unknown', + stack: saveErr instanceof Error ? saveErr.stack : undefined, + }); + // Image is lost - this is a critical failure, re-throw for visibility + throw saveErr; + } + } + + log.debug(`Sending to onMessage: chatId=${result.chatId}, text=${result.text?.substring(0, 50)}, attachments=${result.attachments?.length ?? 0}, onMessage defined=${!!this.onMessage}`); + if (this.onMessage) { + await this.onMessage(this.enrichWithConversation(result, room)); + } else { + log.debug(`onMessage is not defined!`); + } + } else { + log.debug(`handleTextMessage returned null - message not sent to bot`); + } + } else if (msgtype === "m.audio") { + // Skip audio from known bots (their TTS) — no @mention check for audio + const audioSender = event.getSender(); + if (audioSender && this.commandProcessor?.isIgnoredBot(audioSender)) { + log.info(`Skipping audio from known bot ${audioSender}`); + } else { + await this.handleAudioMessage(event, room); + } + } else if (msgtype === "m.image") { + await this.handleImageMessage(event, room); + } else if (msgtype === "m.file") { + await this.handleFileMessage(event, room); + } + } + + /** + * Per-chat mode: chatId=roomId is all bot.ts needs to route per-room. + * The bot derives key 'matrix:{roomId}' automatically via conversationMode: 'per-chat'. + * This method is retained as a pass-through so call sites don't need updating. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private enrichWithConversation(result: InboundMessage, _room: sdk.Room): InboundMessage { + return result; + } + + private async handleFileMessage(event: sdk.MatrixEvent, room: sdk.Room): Promise { + if (!this.client) return; + + const ourUserId = this.client.getUserId(); + if (!ourUserId) return; + + const result = await handleFileMessage({ + client: this.client, + room, + event, + ourUserId, + uploadDir: this.config.uploadDir ?? process.cwd(), + sendTyping: async (roomId, typing) => { + await this.client!.sendTyping(roomId, typing, 30000); + }, + }); + + if (result) { + await this.onMessage?.(this.enrichWithConversation(result, room)); + } + } + + private async handleAudioMessage(event: sdk.MatrixEvent, room: sdk.Room): Promise { + if (!this.client) return; + + const ourUserId = this.client.getUserId(); + if (!ourUserId) return; + + const result = await handleAudioMessage({ + client: this.client, + room, + event, + ourUserId, + transcriptionEnabled: this.config.transcriptionEnabled, + sttUrl: this.config.sttUrl, + sendTyping: async (roomId, typing) => { + await this.client!.sendTyping(roomId, typing, 60000); + }, + sendMessage: async (roomId, text) => { + await this.sendMessage({ chatId: roomId, text }); + }, + }); + + if (result) { + // Flag this room for TTS response BEFORE onMessage fires so sendMessage() sees it + if (result.isVoiceInput) { + this.pendingVoiceRooms.add(result.chatId); + } + await this.onMessage?.(this.enrichWithConversation(result, room)); + } + } + + private async handleImageMessage(event: sdk.MatrixEvent, room: sdk.Room): Promise { + if (!this.client) return; + + const ourUserId = this.client.getUserId(); + if (!ourUserId) return; + + await handleImageMessage({ + client: this.client, + room, + event, + ourUserId, + imageMaxSize: this.config.imageMaxSize, + sendTyping: async (roomId, typing) => { + await this.client!.sendTyping(roomId, typing, 30000); + }, + sendMessage: async (roomId, text) => { + await this.sendMessage({ chatId: roomId, text }); + }, + addReaction: async (roomId, eventId, emoji) => { + const reactionContent = { + "m.relates_to": { + rel_type: sdk.RelationType.Annotation as string, + event_id: eventId, + key: emoji, + }, + } as ReactionEventContent; + await this.client!.sendEvent(roomId, sdk.EventType.Reaction, reactionContent); + }, + storePendingImage: async (eventId, roomId, imageData, format) => { + this.pendingImages.set(eventId, { + eventId, + roomId, + imageData, + format, + timestamp: Date.now(), + }); + }, + }); + } + + /** + * Upload and send audio message to room + */ + async uploadAndSendAudio(roomId: string, audioData: Buffer): Promise { + if (!this.client) return null; + + try { + // Convert Buffer to Uint8Array for upload + const uint8Array = new Uint8Array(audioData.buffer, audioData.byteOffset, audioData.byteLength); + const blob = new Blob([uint8Array as unknown as BlobPart], { type: "audio/mpeg" }); + + const uploadResponse = await this.client.uploadContent(blob, { + name: "response.mp3", + type: "audio/mpeg", + }); + const mxcUrl = uploadResponse.content_uri; + + // Extract bot name from userId (@username:server -> username) + const botName = this.config.userId.split(":")[0].slice(1) || "Bot"; + const voiceLabel = `${botName}'s voice`; + + const content = { + msgtype: MsgType.Audio, + body: voiceLabel, + url: mxcUrl, + info: { + mimetype: "audio/mpeg", + size: audioData.length, + }, + } as RoomMessageEventContent; + + const response = await this.client.sendMessage(roomId, content); + const eventId = response.event_id; + + this.ourAudioEvents.add(eventId); + log.info(`Audio sent: ${eventId}...`); + + // Add 🎤 reaction for TTS regeneration + const reactionContent = { + "m.relates_to": { + rel_type: sdk.RelationType.Annotation as string, + event_id: eventId, + key: "🎤", + }, + } as ReactionEventContent; + await this.client.sendEvent(roomId, sdk.EventType.Reaction, reactionContent); + + return eventId; + } catch (err) { + log.error("Failed to send audio:", err); + return null; + } + } + + /** + * Send a file/image/audio to a Matrix room. + * Called by bot core when processing and directives. + * For audio: adds 🎤 reaction and stores original text (if caption provided) for regeneration. + */ + async sendFile(file: OutboundFile): Promise<{ messageId: string }> { + if (!this.client) throw new Error('Matrix client not initialized'); + + const { chatId, filePath, kind, caption } = file; + const filename = basename(filePath); + const ext = extname(filePath).toLowerCase(); + + // Determine mimetype + const mimeType = inferMatrixMimeType(ext, kind); + + // Read file from disk + const data = await readFile(filePath); + const blob = new Blob([new Uint8Array(data.buffer, data.byteOffset, data.byteLength)], { type: mimeType }); + + const uploadResponse = await this.client.uploadContent(blob, { name: filename, type: mimeType }); + const mxcUrl = uploadResponse.content_uri; + + // Build room message content + let msgtype: string; + if (kind === 'image') msgtype = MsgType.Image; + else if (kind === 'audio') msgtype = MsgType.Audio; + else msgtype = MsgType.File; + + const content: RoomMessageEventContent = { + msgtype, + body: caption || filename, + url: mxcUrl, + info: { mimetype: mimeType, size: data.length }, + }; + + const response = await this.client.sendMessage(chatId, content); + const eventId = response.event_id; + + // For audio: add 🎤 reaction + store text if available (enables regeneration button) + if (kind === 'audio') { + this.ourAudioEvents.add(eventId); + if (caption) { + this.storage.storeAudioMessage(eventId, 'default', chatId, caption); + } + const reactionContent: ReactionEventContent = { + "m.relates_to": { + rel_type: sdk.RelationType.Annotation as string, + event_id: eventId, + key: "🎤", + }, + }; + await this.client.sendEvent(chatId, sdk.EventType.Reaction, reactionContent); + } + + log.info(`sendFile: sent ${kind ?? 'file'} ${filename} → ${eventId} in ${chatId}`); + return { messageId: eventId }; + } + + /** + * Regenerate TTS for a text message + */ + async regenerateTTS(text: string, roomId: string): Promise { + if (!this.client || !this.config.ttsUrl) return null; + + try { + const audioData = await synthesizeSpeech(text, { + url: this.config.ttsUrl, + voice: this.config.ttsVoice, + }); + + const audioEventId = await this.uploadAndSendAudio(roomId, audioData); + if (audioEventId) { + // Store mapping so 🎤 on the regenerated audio works too + this.storage.storeAudioMessage(audioEventId, "default", roomId, text); + } + return audioEventId; + } catch (err) { + log.error("Failed to regenerate TTS:", err); + return null; + } + } + + /** + * Store audio message text for 🎤 reaction regeneration + */ + storeAudioMessage(messageId: string, conversationId: string, roomId: string, text: string): void { + this.storage.storeAudioMessage(messageId, conversationId, roomId, text); + } + + /** + * Send TTS audio for a text response + */ + async sendAudio(chatId: string, text: string): Promise { + if (!this.config.ttsUrl) return; + + try { + const audioData = await synthesizeSpeech(text, { + url: this.config.ttsUrl, + voice: this.config.ttsVoice, + }); + + const audioEventId = await this.uploadAndSendAudio(chatId, audioData); + if (audioEventId) { + // Store for 🎤 regeneration + this.storage.storeAudioMessage(audioEventId, "default", chatId, text); + } + } catch (err) { + log.error("TTS failed (non-fatal):", err); + } + } + + /** + * Get and consume a pending image for a room + */ + getPendingImage(chatId: string): { imageData: Buffer; format: string } | null { + for (const [key, img] of this.pendingImages.entries()) { + if (img.roomId === chatId) { + this.pendingImages.delete(key); + return { imageData: img.imageData, format: img.format }; + } + } + return null; + } + + /** + * Track a sent message for reaction feedback + */ + onMessageSent(chatId: string, messageId: string, stepId?: string): void { + this.storage.storeMessageMapping(messageId, "default", stepId, "@ani:wiuf.net", chatId); + } + + /** + * Add an emoji reaction to a message + */ + async addReaction(chatId: string, messageId: string, emoji: string): Promise { + if (!this.client) return; + const resolvedEmoji = resolveEmoji(emoji); + const reactionContent = { + "m.relates_to": { + rel_type: sdk.RelationType.Annotation as string, + event_id: messageId, + key: resolvedEmoji, + }, + } as ReactionEventContent; + try { + await this.client.sendEvent(chatId, sdk.EventType.Reaction, reactionContent); + } catch (err: any) { + // Ignore duplicate reaction errors (reaction already exists) + if (err?.errcode === 'M_DUPLICATE_ANNOTATION' || err?.message?.includes('same reaction twice')) { + // Already reacted with this emoji - that's fine + return; + } + throw err; + } + } + + /** + * Remove an emoji reaction from a message + * + * Finds our reaction event by scanning the room timeline for m.reaction + * events from our userId matching the target event_id + key, then redacts it. + */ + async removeReaction(chatId: string, messageId: string, emoji: string): Promise { + if (!this.client) return; + + const resolvedEmoji = resolveEmoji(emoji); + const room = this.client.getRoom(chatId); + if (!room) return; + + const ourUserId = this.client.getUserId(); + if (!ourUserId) return; + + // Scan timeline for our reaction event matching the target message and emoji + const timeline = room.getLiveTimeline(); + const events = timeline.getEvents(); + + for (const event of events) { + if (event.getType() !== sdk.EventType.Reaction) continue; + if (event.getSender() !== ourUserId) continue; + + const content = event.getContent(); + const relatesTo = content?.["m.relates_to"]; + + if ( + relatesTo?.rel_type === sdk.RelationType.Annotation && + relatesTo?.event_id === messageId && + relatesTo?.key === resolvedEmoji + ) { + // Found our reaction - redact it + const eventId = event.getId(); + if (eventId) { + try { + await this.client.redactEvent(chatId, eventId); + log.info(`Removed reaction ${resolvedEmoji} from ${messageId}`); + } catch (err) { + log.warn(`Failed to remove reaction: ${err}`); + } + } + return; + } + } + } + + /** + * Get the storage instance (for reaction handler) + */ + getStorage(): MatrixStorage { + return this.storage; + } + + private async startSync(): Promise { + if (!this.client) return; + + log.info("Starting sync..."); + + // CRITICAL: Set up verification handlers BEFORE startClient() + // Verification events arrive during initial sync, so we must be ready + this.setupVerificationHandler(); + + this.client.startClient({ initialSyncLimit: 10 }); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Initial sync timeout")), 120000); + const checkSync = () => { + if (this.initialSyncDone) { + clearTimeout(timeout); + resolve(); + } else { + setTimeout(checkSync, 100); + } + }; + checkSync(); + }); + } +} + +export function createMatrixAdapter(config: MatrixAdapterConfig): MatrixAdapter { + return new MatrixAdapter(config); +} + +/** + * Infer Matrix mimetype from file extension and/or kind hint. + */ +function inferMatrixMimeType(ext: string, kind?: string): string { + if (kind === 'image') { + const imageTypes: Record = { + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp', + '.tiff': 'image/tiff', '.tif': 'image/tiff', + }; + return imageTypes[ext] ?? 'image/png'; + } + if (kind === 'audio') { + const audioTypes: Record = { + '.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.opus': 'audio/ogg; codecs=opus', + '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.aac': 'audio/aac', + '.flac': 'audio/flac', + }; + return audioTypes[ext] ?? 'audio/mpeg'; + } + // Generic file — map common types, fall back to octet-stream + const fileTypes: Record = { + '.pdf': 'application/pdf', '.txt': 'text/plain', '.csv': 'text/csv', + '.json': 'application/json', '.zip': 'application/zip', + }; + return fileTypes[ext] ?? 'application/octet-stream'; +} diff --git a/src/channels/matrix/commands.ts b/src/channels/matrix/commands.ts new file mode 100644 index 0000000..6e03fe8 --- /dev/null +++ b/src/channels/matrix/commands.ts @@ -0,0 +1,273 @@ +/** + * Matrix Bot Command Processor + * + * Handles !commands sent by allowed users in Matrix rooms: + * !commands — list all available commands + * !pause — silence bot in current room (SQLite persisted) + * !resume — re-enable bot in current room + * !status — show paused rooms, ignored bots, heartbeat state + * !ignorebot-add @u:s — add user to global ignore list (prevents bot loops) + * !ignorebot-remove @u:s — remove user from ignore list + * !heartbeat on/off — toggle the heartbeat cron (in-memory) + * + * Commands run AFTER access control (allowedUsers) but BEFORE the paused-room + * check, so !resume always works even in a paused room. + * Unrecognized !x commands fall through to Letta as normal text. + */ + +import { createLogger } from "../../logger.js"; +import type { MatrixStorage } from "./storage.js"; +const log = createLogger('MatrixCommands'); + +interface CommandCallbacks { + onHeartbeatStop?: () => void; + onHeartbeatStart?: () => void; + isHeartbeatEnabled?: () => boolean; + onTimeoutHeartbeat?: () => void; + getAgentId?: () => string | undefined; + onInvalidateSession?: (key: string) => void; +} + +export class MatrixCommandProcessor { + // Per-room bot-turn counters: roomId → remaining turns + private botTurns = new Map(); + + constructor( + private storage: MatrixStorage, + private callbacks: CommandCallbacks = {}, + ) {} + + /** + * Process a !command. + * Returns: + * - string → send as reply + * - '' → silent ack (no reply sent) + * - undefined → not a recognized command, fall through to Letta + */ + async handleCommand( + body: string, + roomId: string, + sender: string, + roomMeta?: { isDm: boolean; roomName: string }, + ): Promise { + const parts = body.slice(1).trim().split(/\s+/); + const cmd = parts[0]?.toLowerCase(); + const args = parts.slice(1); + + switch (cmd) { + case "commands": + return this.doCommands(); + case "pause": + return this.doPause(roomId, sender); + case "resume": + return this.doResume(roomId); + case "status": + return this.doStatus(roomId); + case "ignorebot-add": + return this.doBotAdd(args[0], sender); + case "ignorebot-remove": + return this.doBotRemove(args[0]); + case "heartbeat": + return this.doHeartbeat(args[0]); + case "restore": + return this.doRestore(args[0], roomId, roomMeta?.isDm ?? false, roomMeta?.roomName ?? roomId); + case "turns": + return this.doTurns(args[0], roomId); + case "timeout": + return this.doTimeout(); + case "new": + return await this.doNew(roomId, roomMeta?.isDm ?? false, roomMeta?.roomName ?? roomId); + case "showreasoning": + return this.doShowReasoning(); + default: + return undefined; + } + } + + isRoomPaused(roomId: string): boolean { + return this.storage.isRoomPaused(roomId); + } + + isIgnoredBot(userId: string): boolean { + return this.storage.isIgnoredBot(userId); + } + + /** + * Check if a bot message should be processed in this room. + * Known bots are silenced UNLESS: + * - The message @mentions our userId (body contains display name or m.mentions) + * - !turns N is active for this room (and decrements the counter) + */ + shouldRespondToBot(roomId: string, body: string, ourUserId: string): boolean { + // Check @mention — body contains our display name or full user ID + const displayName = ourUserId.match(/^@([^:]+):/)?.[1]; + if (displayName && body.toLowerCase().includes(displayName.toLowerCase())) { + return true; + } + if (body.includes(ourUserId)) { + return true; + } + + // Check !turns counter + const remaining = this.botTurns.get(roomId); + if (remaining !== undefined && remaining > 0) { + this.botTurns.set(roomId, remaining - 1); + log.info(`[Commands] !turns: ${remaining - 1} turns remaining in ${roomId}`); + if (remaining - 1 === 0) this.botTurns.delete(roomId); + return true; + } + + return false; + } + + // ─── Command implementations ───────────────────────────────────────────── + + private doCommands(): string { + const lines = [ + "📜 **Available Commands**", + "", + "**Bot Control**", + " `!pause` — Silence bot in current room", + " `!resume` — Re-enable bot in current room", + " `!status` — Show bot status, paused rooms, heartbeat state", + "", + "**Bot Loop Prevention**", + " `!ignorebot-add @user:server` — Add bot to ignore list", + " `!ignorebot-remove @user:server` — Remove from ignore list", + " `!turns N` (1-50) — Respond to bot messages for N turns", + "", + "**Conversation Management**", + " `!new` — Create fresh Letta conversation for this room", + " `!restore conv-xxxx` — Point room at specific conversation", + " `!showreasoning` — Show current reasoning display status", + "", + "**Heartbeat Control**", + " `!heartbeat on/off` — Toggle heartbeat cron", + " `!timeout` — Kill stuck heartbeat run", + ]; + return lines.join("\n"); + } + + private doPause(roomId: string, sender: string): string { + this.storage.pauseRoom(roomId, sender); + return "⏸️ Bot paused in this room. Use !resume to re-enable."; + } + + private doResume(roomId: string): string { + this.storage.resumeRoom(roomId); + return "▶️ Bot resumed in this room."; + } + + private doStatus(roomId: string): string { + const paused = this.storage.getPausedRooms(); + const ignored = this.storage.getIgnoredBots(); + const hbState = this.callbacks.isHeartbeatEnabled?.() ? "on" : "off"; + const thisRoomPaused = this.storage.isRoomPaused(roomId); + + const turnsRemaining = this.botTurns.get(roomId); + const lines = [ + "📊 **Bot Status**", + `This room: ${thisRoomPaused ? "⏸️ paused" : "▶️ active"}`, + `Conversation key: \`matrix:${roomId}\``, + turnsRemaining ? `Bot turns: ${turnsRemaining} remaining` : "Bot turns: off (observer mode in multi-bot rooms)", + paused.length > 0 ? `Paused rooms: ${paused.length}` : "No rooms paused", + ignored.length > 0 + ? `Known bots:\n${ignored.map((u) => ` • ${u}`).join("\n")}` + : "No known bots", + `Heartbeat: ${hbState}`, + ]; + + return lines.join("\n"); + } + + private doBotAdd(userId: string | undefined, sender: string): string { + if (!userId?.startsWith("@")) { + return "⚠️ Usage: !ignorebot-add @user:server"; + } + this.storage.addIgnoredBot(userId, sender); + return `🚫 Added ${userId} to ignore list`; + } + + private doBotRemove(userId: string | undefined): string { + if (!userId?.startsWith("@")) { + return "⚠️ Usage: !ignorebot-remove @user:server"; + } + this.storage.removeIgnoredBot(userId); + return `✅ Removed ${userId} from ignore list`; + } + + private doHeartbeat(arg: string | undefined): string { + const normalized = arg?.toLowerCase(); + if (normalized === "off" || normalized === "stop") { + this.callbacks.onHeartbeatStop?.(); + return "⏸️ Heartbeat cron stopped"; + } + if (normalized === "on" || normalized === "start") { + this.callbacks.onHeartbeatStart?.(); + return "▶️ Heartbeat cron started"; + } + return "⚠️ Usage: !heartbeat on | !heartbeat off"; + } + + private doTurns(arg: string | undefined, roomId: string): string { + const n = parseInt(arg || "", 10); + if (!n || n < 1 || n > 50) { + const current = this.botTurns.get(roomId); + if (current) return `🔄 ${current} bot turns remaining in this room`; + return "⚠️ Usage: !turns N (1-50) — respond to bot messages for the next N turns"; + } + this.botTurns.set(roomId, n); + return `🔄 Will respond to bot messages for the next ${n} turns in this room`; + } + + private doRestore( + _convId: string | undefined, + _roomId: string, + _isDm: boolean, + _roomName: string, + ): string { + return "ℹ️ !restore is no longer needed — each room has its own persistent conversation via per-chat mode.\nUse !new to start a fresh conversation."; + } + + private doTimeout(): string { + if (this.callbacks.onTimeoutHeartbeat) { + this.callbacks.onTimeoutHeartbeat(); + return "⏹ Killing stuck heartbeat run"; + } + return "⚠️ No heartbeat timeout handler registered"; + } + + private async doNew( + roomId: string, + isDm: boolean, + roomName: string, + ): Promise { + const agentId = this.callbacks.getAgentId?.(); + if (!agentId) { + return "⚠️ No agent ID available"; + } + if (!this.callbacks.onInvalidateSession) { + return "⚠️ Session reset not available (onInvalidateSession not wired)"; + } + // In per-chat mode the conversation key is 'matrix:{roomId}' + const key = `matrix:${roomId}`; + this.callbacks.onInvalidateSession(key); + log.info(`!new: invalidated session for key ${key}`); + return `✓ Fresh conversation started for ${isDm ? "this DM" : roomName}. Next message will begin a new session.`; + } + + private doShowReasoning(): string { + return [ + "🧠 **Reasoning Text Display**", + "", + "Controls whether the agent's thinking/reasoning text is shown in chat.", + "The 🧠 emoji always appears when reasoning starts — this setting controls the text.", + "", + "**Configuration:** Set `display.showReasoning` in your `lettabot.yaml`.", + " - `true`: Show reasoning text in a collapsible block", + " - `false`: Hide reasoning text (only final response shown)", + "", + "Restart the bot after changing config.", + ].join('\n'); + } +} diff --git a/src/channels/matrix/crypto.ts b/src/channels/matrix/crypto.ts new file mode 100644 index 0000000..e77620b --- /dev/null +++ b/src/channels/matrix/crypto.ts @@ -0,0 +1,289 @@ +/** + * Matrix E2EE Crypto Utilities + * + * Handles initialization and management of Matrix end-to-end encryption. + * Uses rust crypto (v28) via initRustCrypto() for Node.js. + * + * Based on the Python bridge approach: + * - Uses bootstrapSecretStorage with recovery key + * - Uses bootstrapCrossSigning for cross-signing setup + * - Sets trust to allow unverified devices (TOFU model) + */ + +import { createLogger } from "../../logger.js"; +import * as sdk from "matrix-js-sdk"; +import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto/recoverykey.js"; + +const log = createLogger('MatrixCrypto'); + +interface CryptoConfig { + enableEncryption: boolean; + recoveryKey?: string; + storeDir: string; + password?: string; + userId?: string; +} + +/** + * Get crypto callbacks for the Matrix client + * These are needed for secret storage operations + */ +export function getCryptoCallbacks(recoveryKey?: string): sdk.ICryptoCallbacks { + return { + getSecretStorageKey: async ( + { keys }: { keys: Record }, + name: string, + ): Promise<[string, Uint8Array] | null> => { + if (!recoveryKey) { + log.info("[MatrixCrypto] No recovery key provided, cannot retrieve secret storage key"); + return null; + } + + // Get the key ID from the keys object + // The SDK passes { keys: { [keyId]: keyInfo } }, and we need to return one we have + const keyIds = Object.keys(keys); + if (keyIds.length === 0) { + log.info("[MatrixCrypto] No secret storage key IDs requested"); + return null; + } + + // Use the first available key ID + const keyId = keyIds[0]; + log.info(`[MatrixCrypto] Providing secret storage key for keyId: ${keyId}, name: ${name}`); + + // Convert recovery key to Uint8Array + // Recovery key uses Matrix's special format with prefix, parity byte, etc. + try { + const keyBytes = decodeRecoveryKey(recoveryKey); + log.info(`[MatrixCrypto] Decoded recovery key, length: ${keyBytes.length} bytes`); + return [keyId, keyBytes]; + } catch (err) { + log.error("[MatrixCrypto] Failed to decode recovery key:", err); + return null; + } + }, + // Cache the key to avoid prompting multiple times + cacheSecretStorageKey: (keyId: string, _keyInfo: any, key: Uint8Array): void => { + log.info(`[MatrixCrypto] Cached secret storage key: ${keyId}`); + }, + }; +} + +/** + * Initialize E2EE for a Matrix client using rust crypto + * + * This follows the Python bridge pattern: + * 1. Initialize rust crypto + * 2. Bootstrap secret storage with recovery key + * 3. Bootstrap cross-signing + * 4. Set trust settings for TOFU (Trust On First Use) + */ +export async function initE2EE( + client: sdk.MatrixClient, + config: CryptoConfig, +): Promise { + if (!config.enableEncryption) { + log.info("[MatrixCrypto] Encryption disabled"); + return; + } + + log.info("[MatrixCrypto] E2EE enabled"); + + try { + // useIndexedDB: false — ephemeral crypto mode. + // Rust WASM crypto triggers TransactionInactiveError with IndexedDB persistence. + // Upstream issue: matrix-org/matrix-rust-sdk-crypto-wasm#195 + // Workaround: fresh device on every restart, cross-signing auto-verifies. + log.info("[MatrixCrypto] Initializing rust crypto (ephemeral mode)..."); + + await client.initRustCrypto({ useIndexedDB: false }); + + const crypto = client.getCrypto(); + if (!crypto) { + throw new Error("Crypto not initialized after initRustCrypto"); + } + + log.info("[MatrixCrypto] Rust crypto initialized"); + + // CRITICAL: Trigger outgoing request loop to upload device keys + // Without this, the device shows as "doesn't support encryption" + log.info("[MatrixCrypto] Triggering key upload..."); + (crypto as any).outgoingRequestLoop(); + // Give it a moment to process + await new Promise(resolve => setTimeout(resolve, 2000)); + log.info("[MatrixCrypto] Key upload triggered"); + + // Force a device key query to get the list of devices for this user + // This is needed to verify signatures on the key backup + if (config.userId) { + log.info("[MatrixCrypto] Fetching device list..."); + try { + await crypto.getUserDeviceInfo([config.userId]); + // Wait a bit for the key query to complete - this is async in the background + await new Promise((resolve) => setTimeout(resolve, 2000)); + log.info("[MatrixCrypto] Device list fetched"); + } catch (err) { + log.warn("[MatrixCrypto] Failed to fetch device list:", err); + } + } + + // Import backup decryption key from recovery key + // The recovery key IS the backup decryption key - when decoded it gives us + // the raw private key needed to decrypt keys from server-side backup + if (config.recoveryKey) { + log.info("[MatrixCrypto] Importing backup decryption key from recovery key..."); + try { + const backupKey = decodeRecoveryKey(config.recoveryKey); + await crypto.storeSessionBackupPrivateKey(backupKey); + log.info("[MatrixCrypto] Backup decryption key stored successfully"); + } catch (err) { + log.warn("[MatrixCrypto] Failed to store backup key:", err); + } + + log.info("[MatrixCrypto] Bootstrapping secret storage..."); + try { + await crypto.bootstrapSecretStorage({}); + log.info("[MatrixCrypto] Secret storage bootstrapped"); + } catch (err) { + log.warn("[MatrixCrypto] Secret storage bootstrap failed (may already exist):", err); + } + + // Bootstrap cross-signing - this will READ existing keys from secret storage + // DO NOT use setupNewCrossSigning: true as that would create new keys + log.info("[MatrixCrypto] Bootstrapping cross-signing..."); + try { + await crypto.bootstrapCrossSigning({ + // Only read existing keys from secret storage, don't create new ones + // This preserves the user's existing cross-signing identity + authUploadDeviceSigningKeys: async (makeRequest: any) => { + log.info("[MatrixCrypto] Uploading cross-signing keys with auth..."); + // Try with password auth if available + if (config.password && config.userId) { + await makeRequest({ + type: "m.login.password", + user: config.userId, + password: config.password, + }); + return; + } + await makeRequest({}); + return; + }, + }); + log.info("[MatrixCrypto] Cross-signing bootstrapped"); + } catch (err) { + log.warn("[MatrixCrypto] Cross-signing bootstrap failed:", err); + } + } + + // Enable trusting cross-signed devices (similar to Python's TrustState.UNVERIFIED) + // This allows the bot to receive encrypted messages without interactive verification + crypto.setTrustCrossSignedDevices(true); + + // CRITICAL: Disable global blacklist of unverified devices + // This is the TypeScript equivalent of Python's allow_key_share + // When false, the bot will: + // 1. Encrypt messages for unverified devices + // 2. Accept room key requests from unverified devices + crypto.globalBlacklistUnverifiedDevices = false; + log.info("[MatrixCrypto] Trusting cross-signed devices enabled"); + log.info("[MatrixCrypto] Unverified devices globally enabled (auto-key-share equivalent)"); + + log.info("[MatrixCrypto] Crypto initialization complete"); + log.info("[MatrixCrypto] NOTE: Key backup check will run after first sync when device list is populated"); + } catch (err) { + log.error("[MatrixCrypto] Failed to initialize crypto:", err); + throw err; + } +} + +/** + * Mark all devices for a user as verified (TOFU - Trust On First Use) + * This is called after sync completes to trust devices we've seen + */ +/** + * Check and enable key backup after sync completes + * This must be called AFTER the initial sync so device list is populated + */ +export async function checkAndRestoreKeyBackup( + client: sdk.MatrixClient, + recoveryKey?: string, +): Promise { + const crypto = client.getCrypto(); + if (!crypto || !recoveryKey) return; + + log.info("[MatrixCrypto] Checking key backup after sync..."); + try { + const backupInfo = await crypto.checkKeyBackupAndEnable(); + if (backupInfo) { + log.info("[MatrixCrypto] Key backup enabled"); + // Check if backup exists before trying to restore + try { + // Verify backup version exists on server + await client.getKeyBackupVersion(); + log.info("[MatrixCrypto] Backup version exists on server"); + + // Restore keys from backup + log.info("[MatrixCrypto] Restoring keys from backup..."); + const backupKey = decodeRecoveryKey(recoveryKey); + const restoreResult = await (client as any).restoreKeyBackup( + backupKey, + undefined, // all rooms + undefined, // all sessions + backupInfo.backupInfo, + ); + log.info(`[MatrixCrypto] Restored ${restoreResult.imported} keys from backup`); + } catch (backupErr: any) { + if (backupErr.errcode === 'M_NOT_FOUND' || backupErr.httpStatus === 404) { + log.info("[MatrixCrypto] Key backup not found on server, skipping restore"); + // Don't treat this as an error - the backup may not exist yet + } else { + log.warn("[MatrixCrypto] Error accessing key backup:", backupErr); + } + } + } else { + log.info("[MatrixCrypto] No trusted key backup available"); + } + } catch (err) { + log.warn("[MatrixCrypto] Key backup check failed:", err); + } +} + +export async function trustUserDevices( + client: sdk.MatrixClient, + userId: string, +): Promise { + const crypto = client.getCrypto(); + if (!crypto) return; + + try { + log.info(`[MatrixCrypto] Trusting devices for ${userId}...`); + + // Get all devices for this user + const devices = await crypto.getUserDeviceInfo([userId]); + const userDevices = devices.get(userId); + + if (!userDevices || userDevices.size === 0) { + log.info(`[MatrixCrypto] No devices found for ${userId}`); + return; + } + + let verifiedCount = 0; + for (const [deviceId, deviceInfo] of Array.from(userDevices.entries())) { + // Skip our own device + if (deviceId === client.getDeviceId()) continue; + + // Check current verification status + const status = await crypto.getDeviceVerificationStatus(userId, deviceId); + if (!status?.isVerified()) { + log.info(`[MatrixCrypto] Marking device ${deviceId} as verified`); + await crypto.setDeviceVerified(userId, deviceId, true); + verifiedCount++; + } + } + + log.info(`[MatrixCrypto] Verified ${verifiedCount} devices for ${userId}`); + } catch (err) { + log.error(`[MatrixCrypto] Failed to trust devices for ${userId}:`, err); + } +} diff --git a/src/channels/matrix/handlers/audio.ts b/src/channels/matrix/handlers/audio.ts new file mode 100644 index 0000000..572ae73 --- /dev/null +++ b/src/channels/matrix/handlers/audio.ts @@ -0,0 +1,143 @@ +import { createLogger } from '../../../logger.js'; +const log = createLogger('MatrixAudio'); +/** + * Audio Handler for Matrix Adapter + * + * Handles incoming audio messages, transcription via STT, + * and coordinates TTS audio response generation. + */ + +import type * as sdk from "matrix-js-sdk"; +import type { InboundMessage } from "../../../core/types.js"; +import { transcribeAudio } from "../stt.js"; +import { downloadAndDecryptMedia, type EncryptionInfo } from "../media.js"; + +export interface AudioHandlerContext { + client: sdk.MatrixClient; + room: sdk.Room; + event: sdk.MatrixEvent; + ourUserId: string; + + // Configuration + transcriptionEnabled: boolean; + sttUrl?: string; + + // Callbacks + sendTyping: (roomId: string, typing: boolean) => Promise; + sendMessage: (roomId: string, text: string) => Promise; +} + +interface AudioInfo { + mxcUrl?: string; + encryptionInfo?: EncryptionInfo; +} + +/** + * Handle audio messages + */ +export async function handleAudioMessage( + ctx: AudioHandlerContext, +): Promise { + const { client, room, event, ourUserId } = ctx; + + const sender = event.getSender(); + const roomId = room.roomId; + + if (!sender || !roomId) return null; + + log.info(`Audio from ${sender} in ${roomId}`); + + // Send typing indicator (STT takes time) + await ctx.sendTyping(roomId, true); + + try { + const content = event.getContent(); + const audioInfo = extractAudioInfo(content); + + if (!audioInfo.mxcUrl) { + throw new Error("No audio URL found"); + } + + // Download and decrypt audio (handles auth + E2EE) + const audioData = await downloadAndDecryptMedia(client, audioInfo.mxcUrl, audioInfo.encryptionInfo); + log.info(`Downloaded ${audioData.length} bytes`); + + // Transcribe if enabled + if (!ctx.transcriptionEnabled) { + await ctx.sendTyping(roomId, false); + await ctx.sendMessage(roomId, "Audio received (transcription disabled)"); + return null; + } + + const transcription = await transcribeAudio(audioData, { + url: ctx.sttUrl, + model: "small", + }); + + if (!transcription || isTranscriptionFailed(transcription)) { + await ctx.sendTyping(roomId, false); + await ctx.sendMessage(roomId, "No speech detected in audio"); + return null; + } + + log.info(`Transcribed: "${transcription.slice(0, 50)}..."`); + + // Voice context prefix + const voiceMessage = `[VOICE] "${transcription}"`; + + const isDm = isDirectMessage(room); + const roomName = room.name || room.getCanonicalAlias() || roomId; + + await ctx.sendTyping(roomId, false); + + return { + channel: "matrix", + chatId: roomId, + userId: sender, + userName: room.getMember(sender)?.name || sender, + userHandle: sender, + messageId: event.getId() || undefined, + text: voiceMessage, + timestamp: new Date(event.getTs()), + isGroup: !isDm, + groupName: isDm ? undefined : roomName, + isVoiceInput: true, // Mark as voice so bot.ts knows to generate audio response + }; + } catch (err) { + log.error("Failed to process audio:", err); + await ctx.sendTyping(roomId, false); + await ctx.sendMessage(roomId, `Failed to process audio: ${err instanceof Error ? err.message : "Unknown error"}`); + return null; + } +} + +function extractAudioInfo(content: Record): AudioInfo { + const file = content.file as Record | undefined; + const url = content.url as string | undefined; + + if (file?.url) { + return { + mxcUrl: file.url as string, + encryptionInfo: { + key: file.key as { k: string }, + iv: file.iv as string, + hashes: file.hashes as { sha256: string }, + }, + }; + } + + if (url) { + return { mxcUrl: url }; + } + + return {}; +} + + +function isDirectMessage(room: sdk.Room): boolean { + return room.getJoinedMembers().length === 2; +} + +function isTranscriptionFailed(text: string): boolean { + return text.startsWith("[") && text.includes("Error"); +} diff --git a/src/channels/matrix/handlers/file.ts b/src/channels/matrix/handlers/file.ts new file mode 100644 index 0000000..8a458be --- /dev/null +++ b/src/channels/matrix/handlers/file.ts @@ -0,0 +1,153 @@ +import { createLogger } from '../../../logger.js'; +const log = createLogger('MatrixFile'); +/** + * File Handler for Matrix Adapter + * + * Handles incoming file messages (PDFs, docs, etc.) by downloading, + * decrypting, and saving to disk so the agent can process them via + * shell tools (pdftotext, cat, etc.) + * + * Files are saved to: {uploadDir}/uploads/YYYY-MM/{filename} + */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type * as sdk from "matrix-js-sdk"; +import type { InboundMessage } from "../../../core/types.js"; +import { downloadAndDecryptMedia, type EncryptionInfo } from "../media.js"; + +export interface FileHandlerContext { + client: sdk.MatrixClient; + room: sdk.Room; + event: sdk.MatrixEvent; + ourUserId: string; + + // Base directory for uploads (e.g. process.cwd()) + uploadDir: string; + + // Callbacks + sendTyping: (roomId: string, typing: boolean) => Promise; +} + +interface FileInfo { + mxcUrl?: string; + encryptionInfo?: EncryptionInfo; + filename: string; + mimetype: string; + size?: number; +} + +/** + * Handle generic file messages (m.file) + */ +export async function handleFileMessage( + ctx: FileHandlerContext, +): Promise { + const { client, room, event, ourUserId } = ctx; + + const sender = event.getSender(); + const roomId = room.roomId; + + if (!sender || sender === ourUserId) return null; + if (!roomId) return null; + + const content = event.getContent(); + if (!content) return null; + + const fileInfo = extractFileInfo(content, event.getId() || "unknown"); + + log.info(`File from ${sender} in ${roomId}: ${fileInfo.filename} (${fileInfo.mimetype})`); + + if (!fileInfo.mxcUrl) { + log.warn("No URL found in file event"); + return null; + } + + await ctx.sendTyping(roomId, true); + + try { + // Download and decrypt (handles auth + E2EE) + const fileData = await downloadAndDecryptMedia(client, fileInfo.mxcUrl, fileInfo.encryptionInfo); + log.info(`Downloaded ${fileData.length} bytes`); + + // Build upload path: uploads/YYYY-MM/filename + const now = new Date(); + const monthDir = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; + const uploadPath = join(ctx.uploadDir, "uploads", monthDir); + mkdirSync(uploadPath, { recursive: true }); + + const savePath = join(uploadPath, fileInfo.filename); + writeFileSync(savePath, fileData); + + // Relative path for agent (relative to uploadDir / process.cwd()) + const relativePath = join("uploads", monthDir, fileInfo.filename); + const sizeKB = fileInfo.size ? `${Math.round(fileInfo.size / 1024)} KB` : `${Math.round(fileData.length / 1024)} KB`; + + log.info(`Saved to ${savePath}`); + + await ctx.sendTyping(roomId, false); + + const isDm = room.getJoinedMembers().length === 2; + const roomName = room.name || room.getCanonicalAlias() || roomId; + + const text = [ + `[File received: ${fileInfo.filename} (${fileInfo.mimetype}, ${sizeKB})`, + `Saved to: ${relativePath}`, + `Use shell tools to read it (pdftotext, cat, head, strings, etc.)]`, + ].join("\n"); + + return { + channel: "matrix", + chatId: roomId, + userId: sender, + userName: room.getMember(sender)?.name || sender, + userHandle: sender, + messageId: event.getId() || undefined, + text, + timestamp: new Date(event.getTs()), + isGroup: !isDm, + groupName: isDm ? undefined : roomName, + }; + } catch (err) { + log.error("Failed to process file:", err); + await ctx.sendTyping(roomId, false); + return null; + } +} + +function extractFileInfo(content: Record, eventId: string): FileInfo { + const file = content.file as Record | undefined; + const url = content.url as string | undefined; + const info = content.info as Record | undefined; + + // Sanitize filename: strip path separators, collapse spaces + const rawName = (content.body as string | undefined) || eventId; + const filename = rawName + .replace(/[/\\]/g, "_") + .replace(/\s+/g, "_") + .replace(/[^a-zA-Z0-9._\-]/g, "_") + .slice(0, 200) || "file"; + + const mimetype = (info?.mimetype as string | undefined) || "application/octet-stream"; + const size = info?.size as number | undefined; + + if (file?.url) { + return { + mxcUrl: file.url as string, + encryptionInfo: { + key: file.key as { k: string }, + iv: file.iv as string, + hashes: file.hashes as { sha256: string }, + }, + filename, + mimetype, + size, + }; + } + + if (url) { + return { mxcUrl: url, filename, mimetype, size }; + } + + return { filename, mimetype, size }; +} diff --git a/src/channels/matrix/handlers/image.ts b/src/channels/matrix/handlers/image.ts new file mode 100644 index 0000000..b65af07 --- /dev/null +++ b/src/channels/matrix/handlers/image.ts @@ -0,0 +1,142 @@ +import { createLogger } from '../../../logger.js'; +const log = createLogger('MatrixImage'); +/** + * Image Handler for Matrix Adapter + * + * Handles incoming image messages with pending queue pattern. + */ + +import type * as sdk from "matrix-js-sdk"; +import type { InboundMessage } from "../../../core/types.js"; +import { downloadAndDecryptMedia, type EncryptionInfo } from "../media.js"; + +export interface ImageHandlerContext { + client: sdk.MatrixClient; + room: sdk.Room; + event: sdk.MatrixEvent; + ourUserId: string; + imageMaxSize: number; + + // Callbacks + sendTyping: (roomId: string, typing: boolean) => Promise; + sendMessage: (roomId: string, text: string) => Promise; + addReaction: (roomId: string, eventId: string, emoji: string) => Promise; + storePendingImage: (eventId: string, roomId: string, imageData: Buffer, format: string) => Promise; +} + +interface ImageInfo { + mxcUrl?: string; + encryptionInfo?: EncryptionInfo; +} + +/** + * Handle image messages + */ +export async function handleImageMessage( + ctx: ImageHandlerContext, +): Promise { + const { client, room, event, ourUserId, imageMaxSize } = ctx; + + const sender = event.getSender(); + const roomId = room.roomId; + const eventId = event.getId(); + + if (!sender || !roomId || !eventId) return null; + + log.info(`Image from ${sender} in ${roomId}`); + + // Send typing indicator (image processing takes time) + await ctx.sendTyping(roomId, true); + + try { + const content = event.getContent(); + + // Get image URL and encryption info + const imageInfo = extractImageInfo(content); + if (!imageInfo.mxcUrl) { + throw new Error("No image URL found"); + } + + // Add ✅ reaction BEFORE download (so user sees we got it) + await ctx.addReaction(roomId, eventId, "✅"); + + // Download and decrypt image (handles auth + E2EE) + let imageData = await downloadAndDecryptMedia(client, imageInfo.mxcUrl, imageInfo.encryptionInfo); + log.info(`Downloaded ${imageData.length} bytes`); + + // Detect format + const format = detectImageFormat(imageData); + log.info(`Format: ${format}`); + + // Process image (placeholder - would resize with sharp) + // For now, just validate size + if (imageData.length > 10 * 1024 * 1024) { + throw new Error("Image too large (max 10MB)"); + } + + // Stop typing + await ctx.sendTyping(roomId, false); + + // Store pending image + await ctx.storePendingImage(eventId, roomId, imageData, format); + + log.info(`Image queued, awaiting text`); + + // Return null - image is pending, will be combined with next text + return null; + } catch (err) { + log.error("Failed to process image:", err); + await ctx.sendTyping(roomId, false); + await ctx.sendMessage(roomId, `Failed to process image: ${err instanceof Error ? err.message : "Unknown error"}`); + return null; + } +} + +function extractImageInfo(content: Record): ImageInfo { + const file = content.file as Record | undefined; + const url = content.url as string | undefined; + + if (file?.url) { + return { + mxcUrl: file.url as string, + encryptionInfo: { + key: file.key as { k: string }, + iv: file.iv as string, + hashes: file.hashes as { sha256: string }, + }, + }; + } + + if (url) { + return { mxcUrl: url }; + } + + return {}; +} + + +function detectImageFormat(data: Buffer): string { + if (data.length < 4) return "unknown"; + + // JPEG: FF D8 FF + if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) { + return "jpeg"; + } + + // PNG: 89 50 4E 47 + if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) { + return "png"; + } + + // GIF: 47 49 46 38 + if (data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x38) { + return "gif"; + } + + // WebP: 52 49 46 46 ... 57 45 42 50 + if (data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46) { + return "webp"; + } + + return "unknown"; +} diff --git a/src/channels/matrix/handlers/invite.ts b/src/channels/matrix/handlers/invite.ts new file mode 100644 index 0000000..9d81ab6 --- /dev/null +++ b/src/channels/matrix/handlers/invite.ts @@ -0,0 +1,102 @@ +import { createLogger } from '../../../logger.js'; +const log = createLogger('MatrixInvite'); +/** + * Invite Handler + * + * Handles room membership events (invites, joins, leaves). + */ + +import type * as sdk from "matrix-js-sdk"; +import type { DmPolicy } from "../../../pairing/types.js"; + +interface InviteHandlerContext { + client: sdk.MatrixClient; + event: sdk.MatrixEvent; + member: sdk.RoomMember; + dmPolicy: DmPolicy; + allowedUsers: string[]; + autoAccept: boolean; + storage?: Record; // reserved for future use + ourUserId?: string; +} + +/** + * Handle a room membership event + */ +export async function handleMembershipEvent(ctx: InviteHandlerContext): Promise { + const { client, event, member, dmPolicy, allowedUsers, autoAccept, storage, ourUserId } = ctx; + + const membership = member.membership; + const sender = event.getSender(); + + if (!sender) return; + + switch (membership) { + case "invite": + await handleInvite(client, member, sender, dmPolicy, allowedUsers, autoAccept); + break; + case "join": + handleJoin(member); + break; + case "leave": + handleLeave(member, storage, ourUserId); + break; + } +} + +/** + * Handle an invite + */ +async function handleInvite( + client: sdk.MatrixClient, + member: sdk.RoomMember, + sender: string, + dmPolicy: DmPolicy, + allowedUsers: string[], + autoAccept: boolean, +): Promise { + log.info(`Received invite to ${member.roomId} from ${sender}`); + + if (!autoAccept) { + log.info(`Auto-accept disabled, ignoring invite`); + return; + } + + // Check if we should accept based on policy + if (dmPolicy === "allowlist") { + const isAllowed = allowedUsers.includes(sender); + if (!isAllowed) { + log.info(`Rejecting invite from non-allowed user: ${sender}`); + return; + } + } + + try { + await client.joinRoom(member.roomId); + log.info(`Joined room: ${member.roomId}`); + } catch (err) { + log.error(`Failed to join room: ${err}`); + } +} + +/** + * Handle a join + */ +function handleJoin(member: sdk.RoomMember): void { + log.info(`User ${member.userId} joined ${member.roomId}`); +} + +/** + * Handle a leave + */ +function handleLeave( + member: sdk.RoomMember, + _storage?: Record, + ourUserId?: string, +): void { + log.info(`User ${member.userId} left ${member.roomId}`); + if (ourUserId && member.userId === ourUserId) { + log.info(`Our user left room ${member.roomId}`); + // Conversation history is managed by bot.ts per-chat mode — no local cleanup needed + } +} diff --git a/src/channels/matrix/handlers/message.ts b/src/channels/matrix/handlers/message.ts new file mode 100644 index 0000000..3a07fbb --- /dev/null +++ b/src/channels/matrix/handlers/message.ts @@ -0,0 +1,191 @@ +/** + * Message Handler + * + * Handles text messages and access control for Matrix. + */ + +import type * as sdk from "matrix-js-sdk"; +import type { InboundMessage } from "../../../core/types.js"; +import type { DmPolicy } from "../../../pairing/types.js"; +import { upsertPairingRequest } from "../../../pairing/store.js"; +import { checkDmAccess } from "../../shared/access-control.js"; +import { formatMatrixHTML } from "../html-formatter.js"; +import { createLogger } from "../../../logger.js"; + +const log = createLogger('MatrixMessage'); + +interface MessageHandlerContext { + client: sdk.MatrixClient; + room: sdk.Room; + event: sdk.MatrixEvent; + ourUserId: string; + config: { + selfChatMode: boolean; + dmPolicy: DmPolicy; + allowedUsers: string[]; + }; + sendMessage: (roomId: string, text: string) => Promise; + onCommand?: (command: string, chatId?: string, args?: string) => Promise; + // !commands processor — handles pause/resume/status/ignorebot-add/ignorebot-remove/heartbeat/restore/turns + commandProcessor?: { + handleCommand( + body: string, + roomId: string, + sender: string, + roomMeta?: { isDm: boolean; roomName: string }, + ): Promise; + isRoomPaused(roomId: string): boolean; + isIgnoredBot(userId: string): boolean; + shouldRespondToBot(roomId: string, body: string, ourUserId: string): boolean; + }; +} + +/** + * Handle a text message event + */ +export async function handleTextMessage( + ctx: MessageHandlerContext, +): Promise { + const { client, room, event, ourUserId, config, sendMessage, onCommand } = ctx; + + const sender = event.getSender(); + const content = event.getClearContent() || event.getContent(); + const body = content.body as string; + + if (!sender || !body) return null; + + // Skip our own messages + if (sender === ourUserId) return null; + + // Multi-bot rooms: determine observer mode for known bots + let observeOnly = false; + if (ctx.commandProcessor?.isIgnoredBot(sender)) { + if (!ctx.commandProcessor.shouldRespondToBot(room.roomId, body, ourUserId)) { + observeOnly = true; // forward to Letta for context, suppress response delivery + } + // else: Bot is @mentioning us or !turns is active — process normally (observeOnly stays false) + } + + // Observer messages skip access check, commands, and paused check — + // they go straight to Letta for context building with no side effects. + if (!observeOnly) { + // Check self-chat mode + if (!config.selfChatMode && sender === ourUserId) { + return null; + } + + // Handle slash commands + if (body.startsWith("/")) { + const result = await handleCommand(body, room.roomId, onCommand); + if (result) { + await sendMessage(room.roomId, result); + return null; + } + } + + // Check access control + const access = await checkDmAccess('matrix', sender, config.dmPolicy, config.allowedUsers); + + if (access === "blocked") { + await sendMessage(room.roomId, "Sorry, you're not authorized to use this bot."); + return null; + } + + if (access === "pairing") { + const { code, created } = await upsertPairingRequest("matrix", sender, { + firstName: extractDisplayName(sender), + }); + + if (!code) { + await sendMessage( + room.roomId, + "Too many pending pairing requests. Please try again later.", + ); + return null; + } + + if (created) { + const pairingMessage = `Hi! This bot requires pairing. + +Your code: *${code}* + +Ask the owner to run: +\`lettabot pairing approve matrix ${code}\` + +This code expires in 1 hour.`; + await sendMessage(room.roomId, pairingMessage); + } + return null; + } + + // Handle !commands — only reachable if sender passed access check above + if (body.startsWith("!") && ctx.commandProcessor) { + const isDm = isDirectMessage(room); + const roomMeta = { isDm, roomName: room.name || room.roomId }; + const reply = await ctx.commandProcessor.handleCommand(body, room.roomId, sender, roomMeta); + if (reply !== undefined) { + if (reply) await sendMessage(room.roomId, reply); + return null; + } + // Unrecognized !command — fall through to Letta as normal text + } + + // Drop message if room is paused (allowed users handled commands above so !resume still works) + if (ctx.commandProcessor?.isRoomPaused(room.roomId)) return null; + } + + // Build inbound message + const isDm = isDirectMessage(room); + const messageId = event.getId(); + if (!messageId) { + log.warn(`[MatrixMessage] No messageId for event in room ${room.roomId} (${isDm ? 'DM' : 'group'}), sender=${sender}, body length=${body.length}`); + } + + const message: InboundMessage = { + channel: "matrix", + chatId: room.roomId, + userId: sender, + userName: extractDisplayName(sender), + userHandle: sender, + messageId: messageId || undefined, + text: body, + timestamp: new Date(event.getTs()), + isGroup: !isDm, + groupName: isDm ? undefined : room.name, + }; + + return message; +} + +/** + * Handle a slash command + */ +async function handleCommand( + command: string, + chatId: string, + onCommand?: (command: string, chatId?: string, args?: string) => Promise, +): Promise { + if (!onCommand) return null; + + const parts = command.slice(1).trim().split(/\s+/); + const cmd = parts[0]; + const args = parts.slice(1).join(' ') || undefined; + return await onCommand(cmd, chatId, args); +} + +/** + * Check if a room is a direct message + */ +function isDirectMessage(room: sdk.Room): boolean { + const members = room.getJoinedMembers(); + return members.length === 2; +} + +/** + * Extract display name from Matrix user ID + */ +function extractDisplayName(userId: string): string { + // Extract from @user:server format + const match = userId.match(/^@([^:]+):/); + return match ? match[1] : userId; +} diff --git a/src/channels/matrix/handlers/reaction.ts b/src/channels/matrix/handlers/reaction.ts new file mode 100644 index 0000000..0848ffd --- /dev/null +++ b/src/channels/matrix/handlers/reaction.ts @@ -0,0 +1,106 @@ +import { createLogger } from '../../../logger.js'; +const log = createLogger('MatrixReaction'); +/** + * Reaction Handler + * + * Handles emoji reactions on bot messages: + * - 👍/❤️/👏/🎉 → positive feedback to Letta + * - 👎/😢/😔/❌ → negative feedback to Letta + * - 🎤 → regenerate TTS audio + */ +import type * as sdk from "matrix-js-sdk"; +import { POSITIVE_REACTIONS, NEGATIVE_REACTIONS, SPECIAL_REACTIONS } from "../types.js"; +import type { MatrixStorage } from "../storage.js"; +import { Letta } from '@letta-ai/letta-client'; + +interface ReactionHandlerContext { + client: sdk.MatrixClient; + event: sdk.MatrixEvent; + ourUserId: string; + storage: MatrixStorage; + sendMessage: (roomId: string, text: string) => Promise; + regenerateTTS: (text: string, roomId: string) => Promise; + // Forward non-special reactions to the Letta agent so it can see and respond to them + forwardToLetta?: (text: string, roomId: string, sender: string) => Promise; + // Check if a ✅ reaction targets a pending image — if so, trigger the image send + sendPendingImageToAgent?: (targetEventId: string, roomId: string, sender: string) => boolean; +} + +export async function handleReactionEvent(ctx: ReactionHandlerContext): Promise { + const { event, ourUserId, storage } = ctx; + const content = event.getContent(); + const relatesTo = content["m.relates_to"]; + + if (!relatesTo || relatesTo.rel_type !== "m.annotation") return; + + const reactionKey = relatesTo.key as string; + const targetEventId = relatesTo.event_id as string; + const sender = event.getSender(); + const roomId = event.getRoomId(); + + // Ignore reactions from the bot itself + if (sender === ourUserId) return; + + log.info(`${reactionKey} on ${targetEventId} from ${sender}`); + + // Handle 🎤 → regenerate TTS + if (reactionKey === SPECIAL_REACTIONS.REGENERATE_AUDIO) { + const originalText = storage.getOriginalTextForAudio(targetEventId); + if (originalText && roomId) { + log.info("Regenerating TTS audio"); + await ctx.regenerateTTS(originalText, roomId); + } else { + log.info("No original text found for audio event"); + } + return; + } + + // Handle feedback reactions (👍/👎 etc.) + if (POSITIVE_REACTIONS.has(reactionKey) || NEGATIVE_REACTIONS.has(reactionKey)) { + const isPositive = POSITIVE_REACTIONS.has(reactionKey); + const score = isPositive ? 1.0 : -1.0; + const stepIds = storage.getStepIdsForEvent(targetEventId); + + if (stepIds.length > 0) { + const agentId = process.env.LETTA_AGENT_ID; + if (agentId) { + const client = new Letta({ apiKey: process.env.LETTA_API_KEY || '', baseURL: process.env.LETTA_BASE_URL || 'https://api.letta.com' }); + for (const stepId of stepIds) { + try { + await client.steps.feedback.create(stepId, { feedback: isPositive ? 'positive' : 'negative' }); + log.info(`Feedback ${isPositive ? "+" : "-"} for step ${stepId}: sent`); + } catch (err) { + log.warn(`Feedback for step ${stepId} failed:`, err); + } + } + } + } else { + log.info(`No step IDs mapped for event ${targetEventId}`); + } + // Feedback reactions are still forwarded to Letta so the agent is aware + } + + // ✅ on a pending image → trigger multimodal send (Python bridge parity) + // The pending image stays in the buffer; bot.ts will pick it up via getPendingImage(chatId) + if (reactionKey === '✅' && ctx.sendPendingImageToAgent && sender && roomId) { + const triggered = ctx.sendPendingImageToAgent(targetEventId, roomId, sender); + if (triggered) { + log.info(`✅ triggered pending image send for ${targetEventId}`); + return; // Don't forward as a regular reaction + } + } + + // Forward ALL reactions (including feedback ones) to Letta so the agent can see them + // Format matches Python bridge: "🎭 {sender} reacted with: {emoji}" + if (ctx.forwardToLetta && sender && roomId) { + const reactionMsg = `🎭 ${sender} reacted with: ${reactionKey}`; + log.info(`Forwarding to Letta: ${reactionMsg}`); + await ctx.forwardToLetta(reactionMsg, roomId, sender).catch((err) => { + log.warn("Failed to forward reaction to Letta:", err); + }); + } +} + +export function isSpecialReaction(reaction: string): boolean { + return Object.values(SPECIAL_REACTIONS).includes(reaction as any); +} diff --git a/src/channels/matrix/html-formatter.ts b/src/channels/matrix/html-formatter.ts new file mode 100644 index 0000000..550f24a --- /dev/null +++ b/src/channels/matrix/html-formatter.ts @@ -0,0 +1,159 @@ +/** + * Matrix HTML Formatter + * + * Converts markdown and special syntax to Matrix HTML format. + * Supports spoilers, colors, and other Matrix-specific formatting. + */ + +import { MATRIX_HTML_FORMAT, MATRIX_COLORS } from "./types.js"; +import { EMOJI_ALIASES as EMOJI_ALIAS_TO_UNICODE } from "../shared/emoji.js"; + +interface FormattedMessage { + plain: string; + html: string; +} + +/** + * Format text with Matrix HTML + */ +export function formatMatrixHTML(text: string): FormattedMessage { + // Convert emoji shortcodes first (before HTML escaping) + let plain = convertEmojiShortcodes(text); + let html = escapeHtml(plain); + + // Convert **bold** + html = html.replace(/\*\*(.+?)\*\*/g, "$1"); + plain = plain.replace(/\*\*(.+?)\*\*/g, "$1"); + + // Convert *italic* + html = html.replace(/\*(.+?)\*/g, "$1"); + plain = plain.replace(/\*(.+?)\*/g, "$1"); + + // Convert ```code blocks``` FIRST (before single-backtick, or the single-backtick + // regex will consume the leading/trailing backticks of the fence and break it) + html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { + const langAttr = lang ? ` class="language-${lang}"` : ""; + return `
${code}
`; + }); + + // Convert `code` (single backtick — runs AFTER triple-backtick to avoid interference) + html = html.replace(/`([^`]+)`/g, "$1"); + + // Convert spoilers ||text|| + html = html.replace(/\|\|(.+?)\|\|/g, '$1'); + plain = plain.replace(/\|\|(.+?)\|\|/g, "[spoiler]"); + + // Convert colors {color|text} + html = html.replace(/\{([^}|]+)\|([^}]+)\}/g, (match, color, content) => { + const hexColor = getColorHex(color.trim()); + // `content` is already HTML-escaped (escapeHtml ran on the full string above) + // — do NOT call escapeHtml again or apostrophes become &#039; + return `${content}`; + }); + plain = plain.replace(/\{[^}|]+\|([^}]+)\}/g, "$1"); + + // Convert links + html = html.replace( + /(https?:\/\/[^\s]+)/g, + '$1', + ); + + // Convert newlines to
+ html = html.replace(/\n/g, "
"); + + return { plain, html }; +} + +/** + * Escape HTML special characters + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Get hex color from name or return as-is if already hex + */ +function getColorHex(color: string): string { + // Check if it's already a hex color + if (color.startsWith("#")) { + return color; + } + + // Check predefined colors + const upperColor = color.toUpperCase(); + if (upperColor in MATRIX_COLORS) { + return MATRIX_COLORS[upperColor as keyof typeof MATRIX_COLORS]; + } + + // Default to white if unknown + return MATRIX_COLORS.WHITE; +} + +/** + * Convert emoji shortcodes to Unicode using the unified emoji map. + * Handles both :colon: wrapped and plain aliases. + */ +export function convertEmojiShortcodes(text: string): string { + let result = text; + + // Match :shortcode: pattern and replace with unicode + result = result.replace(/:([a-z0-9_+-]+):/gi, (match, name) => { + const lowerName = name.toLowerCase(); + // Try direct lookup + if (EMOJI_ALIAS_TO_UNICODE[lowerName]) { + return EMOJI_ALIAS_TO_UNICODE[lowerName]; + } + // Try with hyphens replaced by underscores + const withUnderscores = lowerName.replace(/-/g, '_'); + if (EMOJI_ALIAS_TO_UNICODE[withUnderscores]) { + return EMOJI_ALIAS_TO_UNICODE[withUnderscores]; + } + // Not found, return original + return match; + }); + + return result; +} + +/** + * Create a Matrix mention pill + */ +export function createMentionPill(userId: string, displayName?: string): string { + const name = displayName || userId; + return `${escapeHtml(name)}`; +} + +/** + * Create a room mention pill + */ +export function createRoomPill(roomId: string, roomName?: string): string { + const name = roomName || roomId; + return `${escapeHtml(name)}`; +} + +/** + * Format a quote (blockquote) + */ +export function formatQuote(text: string): FormattedMessage { + const lines = text.split("\n"); + const plain = lines.map((line) => `> ${line}`).join("\n"); + const html = `
${escapeHtml(text).replace(/\n/g, "
")}
`; + return { plain, html }; +} + +/** + * Format a list + */ +export function formatList(items: string[], ordered = false): FormattedMessage { + const plain = items.map((item, i) => `${ordered ? `${i + 1}.` : "-"} ${item}`).join("\n"); + const tag = ordered ? "ol" : "ul"; + const htmlItems = items.map((item) => `
  • ${escapeHtml(item)}
  • `).join(""); + const html = `<${tag}>${htmlItems}`; + return { plain, html }; +} diff --git a/src/channels/matrix/index.ts b/src/channels/matrix/index.ts new file mode 100644 index 0000000..2786cd0 --- /dev/null +++ b/src/channels/matrix/index.ts @@ -0,0 +1,12 @@ +/** + * Matrix Channel Adapter + * + * Full-featured Matrix adapter with E2EE, voice transcription, TTS, + * image handling, and interactive reactions. + * + * This replaces the basic matrix-bot-sdk implementation with a + * comprehensive matrix-js-sdk-based adapter. + */ + +export { MatrixAdapter } from "./adapter.js"; +export type { MatrixAdapterConfig } from "./types.js"; diff --git a/src/channels/matrix/indexeddb-polyfill.ts b/src/channels/matrix/indexeddb-polyfill.ts new file mode 100644 index 0000000..f6f7fb2 --- /dev/null +++ b/src/channels/matrix/indexeddb-polyfill.ts @@ -0,0 +1,79 @@ +import { createLogger } from '../../logger.js'; +const log = createLogger('IndexedDB'); +/** + * IndexedDB Polyfill for Node.js — Persistent SQLite Backend + * + * Uses indexeddbshim (backed by sqlite3) to provide a REAL persistent IndexedDB + * implementation. Crypto keys, sessions, and device state are written to SQLite + * databases in databaseDir and survive process restarts. + * + * This replaces the previous fake-indexeddb (in-memory only) approach. + * With persistence, the bot keeps the same device identity across restarts — + * no re-verification needed after code changes. + * + * Storage: {databaseDir}/*.db (one SQLite file per IDB database name) + */ + +import { existsSync, mkdirSync } from "node:fs"; + +interface PolyfillOptions { + databaseDir: string; +} + +let initialized = false; + +/** + * Initialize IndexedDB polyfill with persistent SQLite backend + * + * @param options.databaseDir - Directory where SQLite .db files are stored + */ +export async function initIndexedDBPolyfill(options: PolyfillOptions): Promise { + if (initialized) { + log.info("Polyfill already initialized"); + return; + } + + const { databaseDir } = options; + + // Ensure directory exists + if (!existsSync(databaseDir)) { + mkdirSync(databaseDir, { recursive: true }); + } + + try { + // indexeddbshim v16 — SQLite-backed IndexedDB for Node.js + // Sets global.indexedDB, global.IDBKeyRange, etc. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore — indexeddbshim lacks type declarations + const { default: setGlobalVars } = await import("indexeddbshim/src/node.js"); + + setGlobalVars(null, { + checkOrigin: false, // no origin checks in Node.js + databaseBasePath: databaseDir, // where SQLite .db files live + deleteDatabaseFiles: false, // preserve data across restarts + }); + + initialized = true; + log.info(`Persistent SQLite backend initialized at ${databaseDir}`); + log.info("Crypto state will survive process restarts"); + } catch (err) { + log.error("Failed to initialize persistent backend:", err); + log.warn("Falling back to fake-indexeddb (in-memory, ephemeral)"); + + try { + // @ts-expect-error - no types for auto import + await import("fake-indexeddb/auto"); + initialized = true; + log.info("Fallback: in-memory IndexedDB (keys lost on restart)"); + } catch (fallbackErr) { + log.error("Fallback also failed:", fallbackErr); + } + } +} + +/** + * Check if IndexedDB polyfill is available + */ +export function isIndexedDBAvailable(): boolean { + return initialized && typeof (global as any).indexedDB !== "undefined"; +} diff --git a/src/channels/matrix/media.ts b/src/channels/matrix/media.ts new file mode 100644 index 0000000..07a02de --- /dev/null +++ b/src/channels/matrix/media.ts @@ -0,0 +1,146 @@ +import { createLogger } from '../../logger.js'; +const log = createLogger('MatrixMedia'); +/** + * Matrix Media Download Utilities + * + * Handles authenticated media downloads and E2EE attachment decryption. + * + * Matrix spec v1.11 moved media to authenticated endpoints: + * /_matrix/client/v1/media/download/{serverName}/{mediaId} + * + * E2EE attachments use AES-256-CTR encryption with: + * - Key: base64url-encoded 256-bit AES key (file.key.k) + * - IV: base64-encoded 128-bit counter block (file.iv) + * - Hash: SHA-256 of encrypted data (file.hashes.sha256) + */ + +import type * as sdk from "matrix-js-sdk"; +import { webcrypto } from "crypto"; + +export interface EncryptionInfo { + key: { k: string }; + iv: string; + hashes: { sha256: string }; +} + +/** + * Download a Matrix media file with authentication. + * Tries the authenticated v1 endpoint first, falls back to v3. + */ +export async function downloadMatrixMedia( + client: sdk.MatrixClient, + mxcUrl: string, +): Promise { + if (!mxcUrl.startsWith("mxc://")) { + throw new Error(`Invalid MXC URL: ${mxcUrl}`); + } + + // Parse mxc://serverName/mediaId + const withoutScheme = mxcUrl.slice("mxc://".length); + const slashIndex = withoutScheme.indexOf("/"); + if (slashIndex === -1) throw new Error(`Malformed MXC URL: ${mxcUrl}`); + + const serverName = withoutScheme.slice(0, slashIndex); + const mediaId = withoutScheme.slice(slashIndex + 1); + const homeserver = (client as any).baseUrl || (client as any).getHomeserverUrl?.(); + const accessToken = client.getAccessToken(); + + // Prefer authenticated endpoint (Matrix spec v1.11+) + const authUrl = `${homeserver}/_matrix/client/v1/media/download/${serverName}/${mediaId}`; + const fallbackUrl = `${homeserver}/_matrix/media/v3/download/${serverName}/${mediaId}`; + + const headers: Record = {}; + if (accessToken) { + headers["Authorization"] = `Bearer ${accessToken}`; + } + + for (const url of [authUrl, fallbackUrl]) { + try { + log.info(`Downloading from ${url.substring(0, 80)}...`); + const response = await fetch(url, { headers }); + if (response.ok) { + const data = Buffer.from(await response.arrayBuffer()); + log.info(`Downloaded ${data.length} bytes`); + return data; + } + log.info(`${url.includes("v1") ? "v1" : "v3"} returned ${response.status}, ${url.includes("v1") ? "trying v3..." : "giving up"}`); + } catch (err) { + log.info(`Request failed: ${err}`); + if (url === fallbackUrl) throw err; + } + } + + throw new Error("Failed to download media from both endpoints"); +} + +/** + * Decrypt an AES-256-CTR encrypted Matrix attachment. + * Used for files in E2EE rooms. + */ +export async function decryptAttachment( + encryptedData: Buffer, + encInfo: EncryptionInfo, +): Promise { + const subtle = webcrypto.subtle; + + // Decode base64url key (32 bytes for AES-256) + const keyBytes = Buffer.from(encInfo.key.k, "base64url"); + + // Decode base64 IV (16 bytes) + const iv = Buffer.from(encInfo.iv, "base64"); + if (iv.length !== 16) { + throw new Error(`Invalid IV length: ${iv.length} (expected 16)`); + } + + // Convert Buffer to plain ArrayBuffer for WebCrypto compatibility + // Buffer.buffer is ArrayBufferLike (may be SharedArrayBuffer), but .slice() always returns ArrayBuffer + const encryptedAB = encryptedData.buffer.slice(encryptedData.byteOffset, encryptedData.byteOffset + encryptedData.byteLength) as ArrayBuffer; + const ivAB = iv.buffer.slice(iv.byteOffset, iv.byteOffset + iv.byteLength) as ArrayBuffer; + const keyAB = keyBytes.buffer.slice(keyBytes.byteOffset, keyBytes.byteOffset + keyBytes.byteLength) as ArrayBuffer; + + // Verify SHA-256 hash of encrypted data before decrypting + const hashBuffer = await subtle.digest("SHA-256", encryptedAB); + const hashB64 = Buffer.from(hashBuffer).toString("base64").replace(/=/g, ""); + const expectedHash = encInfo.hashes.sha256.replace(/=/g, ""); + if (hashB64 !== expectedHash) { + throw new Error(`SHA-256 hash mismatch: file may be corrupted`); + } + + // Import AES-256-CTR key + const cryptoKey = await subtle.importKey( + "raw", + keyAB, + { name: "AES-CTR", length: 256 }, + false, + ["decrypt"], + ); + + // Decrypt (AES-256-CTR, 64-bit counter = last 8 bytes of the 16-byte block) + const decrypted = await subtle.decrypt( + { name: "AES-CTR", counter: new Uint8Array(ivAB), length: 64 }, + cryptoKey, + encryptedAB, + ); + + return Buffer.from(decrypted); +} + +/** + * Download and optionally decrypt a Matrix media attachment. + */ +export async function downloadAndDecryptMedia( + client: sdk.MatrixClient, + mxcUrl: string, + encryptionInfo?: EncryptionInfo, +): Promise { + const data = await downloadMatrixMedia(client, mxcUrl); + + if (encryptionInfo) { + log.info(`Decrypting E2EE attachment...`); + const decrypted = await decryptAttachment(data, encryptionInfo); + log.info(`Decrypted: ${data.length} → ${decrypted.length} bytes`); + return decrypted; + } + + return data; +} diff --git a/src/channels/matrix/persistent-crypto-store.ts b/src/channels/matrix/persistent-crypto-store.ts new file mode 100644 index 0000000..cb7e826 --- /dev/null +++ b/src/channels/matrix/persistent-crypto-store.ts @@ -0,0 +1,146 @@ +import { createLogger } from '../../logger.js'; +const log = createLogger('CryptoStore'); +/** + * Persistent Crypto Store for Node.js + * + * Wraps MemoryCryptoStore and serializes to disk on changes + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +interface CryptoData { + deviceKeys?: Record; + rooms?: Record; + sessions?: Record; + inboundGroupSessions?: Record; + outboundGroupSessions?: Record; + userDevices?: Record; + crossSigningInfo?: unknown; + privateKeys?: Record; +} + +export class PersistentCryptoStore { + private data: CryptoData = {}; + private filePath: string; + private memoryStore: any; + + constructor(filePath: string) { + this.filePath = filePath; + this.loadFromDisk(); + this.memoryStore = this.createMemoryStore(); + } + + private loadFromDisk(): void { + try { + if (existsSync(this.filePath)) { + const content = readFileSync(this.filePath, "utf-8"); + this.data = JSON.parse(content); + log.info(`Loaded from ${this.filePath}`); + } + } catch (err) { + log.warn("Failed to load, starting fresh:", err); + this.data = {}; + } + } + + private saveToDisk(): void { + try { + const dir = dirname(this.filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(this.filePath, JSON.stringify(this.data, null, 2)); + } catch (err) { + log.error("Failed to save:", err); + } + } + + private createMemoryStore(): any { + // Simple memory store implementation that syncs to disk + const store = { + getItem: (key: string) => { + return this.data[key as keyof CryptoData]; + }, + setItem: (key: string, value: any) => { + (this.data as any)[key] = value; + this.saveToDisk(); + }, + removeItem: (key: string) => { + delete (this.data as any)[key]; + this.saveToDisk(); + }, + }; + return store; + } + + // Implement the CryptoStore interface + async getDeviceKeys(): Promise | null> { + return this.data.deviceKeys || null; + } + + async setDeviceKeys(keys: Record): Promise { + this.data.deviceKeys = keys; + this.saveToDisk(); + } + + async getRoom(roomId: string): Promise { + return this.data.rooms?.[roomId] || null; + } + + async setRoom(roomId: string, data: unknown): Promise { + if (!this.data.rooms) this.data.rooms = {}; + this.data.rooms[roomId] = data; + this.saveToDisk(); + } + + async getSession(deviceKey: string, sessionId: string): Promise { + return this.data.sessions?.[`${deviceKey}:${sessionId}`] || null; + } + + async setSession(deviceKey: string, sessionId: string, data: unknown): Promise { + if (!this.data.sessions) this.data.sessions = {}; + this.data.sessions[`${deviceKey}:${sessionId}`] = data; + this.saveToDisk(); + } + + async getInboundGroupSession(roomId: string, sessionId: string): Promise { + return this.data.inboundGroupSessions?.[`${roomId}:${sessionId}`] || null; + } + + async setInboundGroupSession(roomId: string, sessionId: string, data: unknown): Promise { + if (!this.data.inboundGroupSessions) this.data.inboundGroupSessions = {}; + this.data.inboundGroupSessions[`${roomId}:${sessionId}`] = data; + this.saveToDisk(); + } + + async getUserDevices(userId: string): Promise | null> { + const devices = this.data.userDevices?.[userId] as Record | undefined; + return devices !== undefined ? devices : null; + } + + async setUserDevices(userId: string, devices: Record): Promise { + if (!this.data.userDevices) this.data.userDevices = {}; + this.data.userDevices[userId] = devices; + this.saveToDisk(); + } + + async getCrossSigningInfo(): Promise { + return this.data.crossSigningInfo || null; + } + + async setCrossSigningInfo(info: unknown): Promise { + this.data.crossSigningInfo = info; + this.saveToDisk(); + } + + async getPrivateKey(keyType: string): Promise { + return this.data.privateKeys?.[keyType] || null; + } + + async setPrivateKey(keyType: string, key: unknown): Promise { + if (!this.data.privateKeys) this.data.privateKeys = {}; + this.data.privateKeys[keyType] = key; + this.saveToDisk(); + } +} diff --git a/src/channels/matrix/queue.ts b/src/channels/matrix/queue.ts new file mode 100644 index 0000000..e4d8bf8 --- /dev/null +++ b/src/channels/matrix/queue.ts @@ -0,0 +1,124 @@ +import { createLogger } from '../../logger.js'; +const log = createLogger('MatrixQueue'); +/** + * Message Queue + * + * Handles message queuing for busy states with size limiting and retry logic. + */ + +import type { QueueItem } from "./types.js"; + +interface QueueConfig { + maxSize?: number; + processIntervalMs?: number; +} + +type QueueProcessor = (item: QueueItem) => Promise; + +export class MessageQueue { + private queue: QueueItem[] = []; + private maxSize: number; + private processIntervalMs: number; + private processor: QueueProcessor | null = null; + private intervalId: NodeJS.Timeout | null = null; + private processing = false; + + constructor(config: QueueConfig = {}) { + this.maxSize = config.maxSize ?? 100; + this.processIntervalMs = config.processIntervalMs ?? 1000; + } + + /** + * Add item to queue + */ + enqueue(item: QueueItem): boolean { + if (this.queue.length >= this.maxSize) { + log.warn("Queue full, dropping message"); + return false; + } + + this.queue.push(item); + log.info(`Enqueued message from ${item.sender} (queue size: ${this.queue.length})`); + return true; + } + + /** + * Start processing queue + */ + startProcessing(processor: QueueProcessor): void { + if (this.intervalId) { + return; // Already running + } + + this.processor = processor; + this.intervalId = setInterval(() => { + this.processNext(); + }, this.processIntervalMs); + + log.info("Started processing"); + } + + /** + * Stop processing queue + */ + stopProcessing(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + this.processor = null; + log.info("Stopped processing"); + } + + /** + * Process next item in queue + */ + private async processNext(): Promise { + if (this.processing || this.queue.length === 0 || !this.processor) { + return; + } + + this.processing = true; + const item = this.queue.shift(); + + if (item) { + try { + await this.processor(item); + } catch (err) { + log.error("Failed to process item:", err); + // Could re-enqueue here if needed + } + } + + this.processing = false; + } + + /** + * Get current queue size + */ + getSize(): number { + return this.queue.length; + } + + /** + * Clear all items from queue + */ + clear(): void { + this.queue = []; + log.info("Cleared all items"); + } + + /** + * Check if queue is full + */ + isFull(): boolean { + return this.queue.length >= this.maxSize; + } + + /** + * Check if queue is empty + */ + isEmpty(): boolean { + return this.queue.length === 0; + } +} diff --git a/src/channels/matrix/session.ts b/src/channels/matrix/session.ts new file mode 100644 index 0000000..0d663c6 --- /dev/null +++ b/src/channels/matrix/session.ts @@ -0,0 +1,164 @@ +/** + * Matrix Session Manager + * + * Handles persistent session storage with backup/restore functionality. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, unlinkSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createLogger } from "../../logger.js"; +import type { MatrixSession } from "./types.js"; + +const log = createLogger('MatrixSession'); + +interface SessionManagerConfig { + sessionFile: string; + backupCount?: number; +} + +export class MatrixSessionManager { + private sessionFile: string; + private backupCount: number; + + constructor(config: SessionManagerConfig) { + this.sessionFile = config.sessionFile; + this.backupCount = config.backupCount ?? 3; + + // Ensure directory exists + const dir = dirname(this.sessionFile); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + } + + /** + * Load session from disk + */ + loadSession(): MatrixSession | null { + try { + if (!existsSync(this.sessionFile)) { + return null; + } + + const data = readFileSync(this.sessionFile, "utf-8"); + const session = JSON.parse(data) as MatrixSession; + + // Validate required fields + if (!session.userId || !session.accessToken) { + log.warn("[MatrixSession] Invalid session data, ignoring"); + return null; + } + + log.info(`[MatrixSession] Loaded session for ${session.userId}`); + return session; + } catch (err) { + log.error("[MatrixSession] Failed to load session:", err); + return null; + } + } + + /** + * Save session to disk with backup + */ + saveSession(session: MatrixSession): void { + try { + // Create backup of existing session + if (existsSync(this.sessionFile)) { + this.rotateBackups(); + } + + // Write new session atomically + const tempFile = `${this.sessionFile}.tmp`; + writeFileSync(tempFile, JSON.stringify(session, null, 2), { mode: 0o600 }); + renameSync(tempFile, this.sessionFile); + + log.info(`[MatrixSession] Saved session for ${session.userId}`); + } catch (err) { + log.error("[MatrixSession] Failed to save session:", err); + throw err; + } + } + + /** + * Rotate backup files + */ + private rotateBackups(): void { + const dir = dirname(this.sessionFile); + const baseName = this.sessionFile.split("/").pop() || "session.json"; + + // Remove oldest backup + const oldestBackup = join(dir, `${baseName}.backup.${this.backupCount}`); + if (existsSync(oldestBackup)) { + unlinkSync(oldestBackup); + } + + // Shift existing backups + for (let i = this.backupCount - 1; i >= 1; i--) { + const oldBackup = join(dir, `${baseName}.backup.${i}`); + const newBackup = join(dir, `${baseName}.backup.${i + 1}`); + if (existsSync(oldBackup)) { + renameSync(oldBackup, newBackup); + } + } + + // Create new backup + const firstBackup = join(dir, `${baseName}.backup.1`); + renameSync(this.sessionFile, firstBackup); + } + + /** + * Restore from most recent backup + */ + restoreFromBackup(): MatrixSession | null { + const dir = dirname(this.sessionFile); + const baseName = this.sessionFile.split("/").pop() || "session.json"; + const firstBackup = join(dir, `${baseName}.backup.1`); + + if (!existsSync(firstBackup)) { + log.warn("[MatrixSession] No backup available to restore"); + return null; + } + + try { + const data = readFileSync(firstBackup, "utf-8"); + const session = JSON.parse(data) as MatrixSession; + log.info(`[MatrixSession] Restored from backup for ${session.userId}`); + return session; + } catch (err) { + log.error("[MatrixSession] Failed to restore from backup:", err); + return null; + } + } + + /** + * Clear session and backups + */ + clearSession(): void { + try { + if (existsSync(this.sessionFile)) { + unlinkSync(this.sessionFile); + } + + const dir = dirname(this.sessionFile); + const baseName = this.sessionFile.split("/").pop() || "session.json"; + + for (let i = 1; i <= this.backupCount; i++) { + const backup = join(dir, `${baseName}.backup.${i}`); + if (existsSync(backup)) { + unlinkSync(backup); + } + } + + log.info("[MatrixSession] Cleared all sessions"); + } catch (err) { + log.error("[MatrixSession] Failed to clear session:", err); + } + } + + /** + * Check if session exists + */ + hasSession(): boolean { + return existsSync(this.sessionFile); + } +} diff --git a/src/channels/matrix/storage.ts b/src/channels/matrix/storage.ts new file mode 100644 index 0000000..348527b --- /dev/null +++ b/src/channels/matrix/storage.ts @@ -0,0 +1,362 @@ +/** + * Matrix Storage + * + * SQLite-based persistent storage for Matrix adapter state. + * Does NOT store room→conversation mappings — that is handled by bot.ts + * per-chat mode (key: 'matrix:{roomId}'). + */ + +import { existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import Database from "better-sqlite3"; +import { createLogger } from "../../logger.js"; + +const log = createLogger('MatrixStorage'); + +interface StorageConfig { + dataDir: string; +} + +export class MatrixStorage { + private db: Database.Database | null = null; + private dataDir: string; + + constructor(config: StorageConfig) { + this.dataDir = config.dataDir; + + // Ensure directory exists + if (!existsSync(this.dataDir)) { + mkdirSync(this.dataDir, { recursive: true }); + } + } + + /** + * Initialize the database + */ + async init(): Promise { + const dbPath = join(this.dataDir, "matrix.db"); + this.db = new Database(dbPath); + + // Enable WAL mode for better concurrency + this.db.pragma("journal_mode = WAL"); + + // Create tables + this.createTables(); + + log.info("[MatrixStorage] Database initialized"); + } + + /** + * Create database tables + */ + private createTables(): void { + if (!this.db) return; + + // Message event mappings for reaction feedback + this.db.exec(` + CREATE TABLE IF NOT EXISTS message_mappings ( + matrix_event_id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + step_id TEXT, + sender TEXT NOT NULL, + room_id TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + `); + + // Audio message mappings for TTS regeneration + this.db.exec(` + CREATE TABLE IF NOT EXISTS audio_messages ( + audio_event_id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + room_id TEXT NOT NULL, + original_text TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + `); + + // Per-room pause state (set via !pause / !resume) + this.db.exec(` + CREATE TABLE IF NOT EXISTS paused_rooms ( + room_id TEXT PRIMARY KEY, + paused_by TEXT NOT NULL, + paused_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + `); + + // Bot ignore list (set via !bot-add / !bot-remove, prevents message loops) + this.db.exec(` + CREATE TABLE IF NOT EXISTS ignored_bots ( + user_id TEXT PRIMARY KEY, + added_by TEXT NOT NULL, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + `); + + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_msg_room ON message_mappings(room_id); + `); + } + + /** + * Store message mapping for reaction tracking + */ + storeMessageMapping( + matrixEventId: string, + conversationId: string, + stepId: string | undefined, + sender: string, + roomId: string, + ): void { + if (!this.db) { + log.warn('[MatrixStorage] storeMessageMapping: Database not initialized'); + return; + } + + try { + const stmt = this.db.prepare(` + INSERT INTO message_mappings (matrix_event_id, conversation_id, step_id, sender, room_id) + VALUES (?, ?, ?, ?, ?) + `); + stmt.run(matrixEventId, conversationId, stepId || null, sender, roomId); + } catch (err) { + log.error(`[MatrixStorage] storeMessageMapping failed for event ${matrixEventId}:`, err); + } + } + + /** + * Get step IDs for a message event + */ + getStepIdsForEvent(matrixEventId: string): string[] { + if (!this.db) { + log.warn('[MatrixStorage] getStepIdsForEvent: Database not initialized'); + return []; + } + + try { + const stmt = this.db.prepare( + "SELECT step_id FROM message_mappings WHERE matrix_event_id = ? AND step_id IS NOT NULL", + ); + const results = stmt.all(matrixEventId) as { step_id: string }[]; + return results.map((r) => r.step_id); + } catch (err) { + log.error(`[MatrixStorage] getStepIdsForEvent failed for event ${matrixEventId}:`, err); + return []; + } + } + + /** + * Store audio message for TTS regeneration + */ + storeAudioMessage( + audioEventId: string, + conversationId: string, + roomId: string, + originalText: string, + ): void { + if (!this.db) { + log.warn('[MatrixStorage] storeAudioMessage: Database not initialized'); + return; + } + + try { + const stmt = this.db.prepare(` + INSERT INTO audio_messages (audio_event_id, conversation_id, room_id, original_text) + VALUES (?, ?, ?, ?) + `); + stmt.run(audioEventId, conversationId, roomId, originalText); + } catch (err) { + log.error(`[MatrixStorage] storeAudioMessage failed for event ${audioEventId}:`, err); + } + } + + /** + * Get original text for audio message + */ + getOriginalTextForAudio(audioEventId: string): string | null { + if (!this.db) { + log.warn('[MatrixStorage] getOriginalTextForAudio: Database not initialized'); + return null; + } + + try { + const stmt = this.db.prepare( + "SELECT original_text FROM audio_messages WHERE audio_event_id = ?", + ); + const result = stmt.get(audioEventId) as { original_text: string } | undefined; + return result?.original_text || null; + } catch (err) { + log.error(`[MatrixStorage] getOriginalTextForAudio failed for event ${audioEventId}:`, err); + return null; + } + } + + // ─── Per-room pause state ───────────────────────────────────────────────── + + pauseRoom(roomId: string, pausedBy: string): void { + if (!this.db) return; + this.db.prepare( + "INSERT INTO paused_rooms (room_id, paused_by) VALUES (?, ?) ON CONFLICT(room_id) DO UPDATE SET paused_by = excluded.paused_by, paused_at = CURRENT_TIMESTAMP", + ).run(roomId, pausedBy); + } + + resumeRoom(roomId: string): void { + if (!this.db) return; + this.db.prepare("DELETE FROM paused_rooms WHERE room_id = ?").run(roomId); + } + + isRoomPaused(roomId: string): boolean { + if (!this.db) return false; + const result = this.db.prepare("SELECT 1 FROM paused_rooms WHERE room_id = ?").get(roomId); + return result !== undefined; + } + + getPausedRooms(): string[] { + if (!this.db) return []; + const rows = this.db.prepare("SELECT room_id FROM paused_rooms").all() as { room_id: string }[]; + return rows.map((r) => r.room_id); + } + + // ─── Bot ignore list ─────────────────────────────────────────────────────── + + addIgnoredBot(userId: string, addedBy: string): void { + if (!this.db) return; + this.db.prepare( + "INSERT INTO ignored_bots (user_id, added_by) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET added_by = excluded.added_by, added_at = CURRENT_TIMESTAMP", + ).run(userId, addedBy); + } + + removeIgnoredBot(userId: string): void { + if (!this.db) return; + this.db.prepare("DELETE FROM ignored_bots WHERE user_id = ?").run(userId); + } + + isIgnoredBot(userId: string): boolean { + if (!this.db) return false; + const result = this.db.prepare("SELECT 1 FROM ignored_bots WHERE user_id = ?").get(userId); + return result !== undefined; + } + + getIgnoredBots(): string[] { + if (!this.db) return []; + const rows = this.db.prepare("SELECT user_id FROM ignored_bots").all() as { user_id: string }[]; + return rows.map((r) => r.user_id); + } + + // ─── Storage Pruning ─────────────────────────────────────────────────────── + + /** + * Prune old entries from audio_messages and message_mappings tables + * Returns array of {table, deletedCount} for each table pruned + * @param retentionDays - Delete entries older than this many days (default: 30) + * @returns Array of pruning results + */ + pruneOldEntries(retentionDays = 30): Array<{ table: string; deletedCount: number }> { + if (!this.db) return []; + + const results: Array<{ table: string; deletedCount: number }> = []; + + try { + // Calculate cutoff date + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - retentionDays); + const cutoffIso = cutoffDate.toISOString(); + + // Prune audio_messages table + const audioStmt = this.db.prepare( + "DELETE FROM audio_messages WHERE created_at < ?" + ); + const audioResult = audioStmt.run(cutoffIso); + results.push({ + table: 'audio_messages', + deletedCount: audioResult.changes + }); + + // Prune message_mappings table + const mappingStmt = this.db.prepare( + "DELETE FROM message_mappings WHERE created_at < ?" + ); + const mappingResult = mappingStmt.run(cutoffIso); + results.push({ + table: 'message_mappings', + deletedCount: mappingResult.changes + }); + + // Log results + if (results.some(r => r.deletedCount > 0)) { + const totalDeleted = results.reduce((sum, r) => sum + r.deletedCount, 0); + log.info( + `[MatrixStorage] Pruned ${totalDeleted} old entry/entries ` + + `(older than ${retentionDays} days): ` + + results.map(r => `${r.table}=${r.deletedCount}`).join(', ') + ); + } + } catch (err) { + log.error('[MatrixStorage] Failed to prune old entries:', err); + } + + return results; + } + + /** + * Get pruning statistics + */ + getPruningStats(): { + audioMessagesCount: number; + messageMappingsCount: number; + oldestAudioMessage: string | null; + oldestMessageMapping: string | null; + } { + if (!this.db) { + return { + audioMessagesCount: 0, + messageMappingsCount: 0, + oldestAudioMessage: null, + oldestMessageMapping: null, + }; + } + + try { + const audioCountStmt = this.db.prepare("SELECT COUNT(*) as count FROM audio_messages"); + const audioCount = (audioCountStmt.get() as { count: number })?.count || 0; + + const mappingCountStmt = this.db.prepare("SELECT COUNT(*) as count FROM message_mappings"); + const mappingCount = (mappingCountStmt.get() as { count: number })?.count || 0; + + const oldestAudioStmt = this.db.prepare( + "SELECT MIN(created_at) as oldest FROM audio_messages" + ); + const oldestAudio = (oldestAudioStmt.get() as { oldest: string })?.oldest || null; + + const oldestMappingStmt = this.db.prepare( + "SELECT MIN(created_at) as oldest FROM message_mappings" + ); + const oldestMapping = (oldestMappingStmt.get() as { oldest: string })?.oldest || null; + + return { + audioMessagesCount: audioCount, + messageMappingsCount: mappingCount, + oldestAudioMessage: oldestAudio, + oldestMessageMapping: oldestMapping, + }; + } catch (err) { + log.error('[MatrixStorage] Failed to get pruning stats:', err); + return { + audioMessagesCount: 0, + messageMappingsCount: 0, + oldestAudioMessage: null, + oldestMessageMapping: null, + }; + } + } + + /** + * Close the database + */ + close(): void { + if (this.db) { + this.db.close(); + this.db = null; + } + } +} diff --git a/src/channels/matrix/stt.ts b/src/channels/matrix/stt.ts new file mode 100644 index 0000000..3d11ee4 --- /dev/null +++ b/src/channels/matrix/stt.ts @@ -0,0 +1,57 @@ +/** + * Speech-to-Text (STT) for Matrix Adapter + */ + +import { createLogger } from "../../logger.js"; +const log = createLogger('MatrixSTT'); + +export interface STTConfig { + url?: string; + language?: string; + model?: string; +} + +export interface STTResult { + text: string; + language?: string; +} + +export async function transcribeAudio( + audioData: Buffer, + config: STTConfig, +): Promise { + const url = config.url || "http://localhost:7862"; + const model = config.model || "small"; + + log.info(`[MatrixSTT] Transcribing ${audioData.length} bytes`); + + try { + const formData = new FormData(); + const blob = new Blob([new Uint8Array(audioData)], { type: "audio/mpeg" }); + formData.append("audio", blob, "audio.mp3"); + + if (config.language) { + formData.append("language", config.language); + } + formData.append("model", model); + + const response = await fetch(`${url}/transcribe`, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error(`STT API error: ${response.status}`); + } + + const result = (await response.json()) as STTResult; + return result.text?.trim() || ""; + } catch (err) { + log.error("[MatrixSTT] Failed:", err); + return `[STT Error] ${err instanceof Error ? err.message : "Unknown"}`; + } +} + +export function isTranscriptionFailed(text: string): boolean { + return text.startsWith("[") && text.includes("Error"); +} diff --git a/src/channels/matrix/tts.ts b/src/channels/matrix/tts.ts new file mode 100644 index 0000000..1dc2479 --- /dev/null +++ b/src/channels/matrix/tts.ts @@ -0,0 +1,179 @@ +/** + * Text-to-Speech (TTS) for Matrix Adapter + * + * Synthesizes text to speech using VibeVoice API. + */ + +import { createLogger } from "../../logger.js"; +const log = createLogger('MatrixTTS'); + +export interface TTSConfig { + url?: string; + voice?: string; + format?: "mp3" | "wav"; + speed?: number; + sampleRate?: number; +} + +export interface VoiceInfo { + id: string; + name: string; + language: string; + gender?: string; +} + +// Pronunciation fixes applied before TTS — word-boundary replacements +const PRONUNCIATION_MAP: Record = { + // Names + "Xzaviar": "X-zay-V-ar", + "xzaviar": "X-zay-V-ar", + "Jean Luc": "Zhan-Look", + "jean luc": "Zhan-Look", + "Sebastian": "Se-BASS-chen", + "sebastian": "Se-BASS-chen", + // Technical terms that TTS often mangles + "API": "A P I", + "SDK": "S D K", + "E2EE": "end-to-end encrypted", + "TTS": "text to speech", + "STT": "speech to text", +}; + +/** + * Apply pronunciation fixes using word-boundary regex + */ +function applyPronunciationFixes(text: string): string { + let result = text; + for (const [wrong, right] of Object.entries(PRONUNCIATION_MAP)) { + result = result.replace(new RegExp(`\\b${escapeRegExp(wrong)}\\b`, "gi"), right); + } + return result; +} + +/** + * Clean text for TTS synthesis — matches Python bridge synthesize_speech() cleaning. + * Call order: control tags → HTML → markdown → code blocks → emojis → pronunciation → whitespace. + */ +export function cleanTextForTTS(text: string): string { + let cleaned = text; + + // Strip agent control tags + cleaned = cleaned.replace(/\[silent\]/gi, ""); + cleaned = cleaned.replace(/\[chromatophore\]/gi, ""); + cleaned = cleaned.replace(/\[!c\]/gi, ""); + cleaned = cleaned.replace(/\[!s\]/gi, ""); + cleaned = cleaned.replace(/\[react:[^\]]*\]/gi, ""); + + // Strip color syntax {color|text} → keep text + cleaned = cleaned.replace(/\{[^}|]+\|([^}]+)\}/g, "$1"); + + // Strip spoilers ||text|| → keep text + cleaned = cleaned.replace(/\|\|(.+?)\|\|/gs, "$1"); + + // Strip HTML tags (keep content) + cleaned = cleaned.replace(/<[^>]+>/g, ""); + + // Remove code blocks entirely (don't read code aloud) + cleaned = cleaned.replace(/```[\s\S]*?```/g, ""); + + // Strip bold and italic markers (keep text) + cleaned = cleaned.replace(/\*\*(.+?)\*\*/g, "$1"); + cleaned = cleaned.replace(/\*(.+?)\*/g, "$1"); + + // Remove inline code markers (keep content) + cleaned = cleaned.replace(/`([^`]+)`/g, "$1"); + + // Convert markdown links to spoken form: [text](url) → text + cleaned = cleaned.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1"); + + // Emoji handling: preserve ✨ and 🎤, strip everything else + // Use marker swap trick from Python bridge + const SPARKLE_MARKER = "__SPARKLE__"; + const MIC_MARKER = "__MIC__"; + cleaned = cleaned.replace(/✨/g, SPARKLE_MARKER); + cleaned = cleaned.replace(/🎤/g, MIC_MARKER); + // Strip remaining emoji (broad Unicode ranges) + cleaned = cleaned.replace( + /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2702}-\u{27B0}\u{24C2}-\u{1F251}\u{1F900}-\u{1FAFF}]/gu, + "" + ); + cleaned = cleaned.replace(new RegExp(SPARKLE_MARKER, "g"), "✨"); + cleaned = cleaned.replace(new RegExp(MIC_MARKER, "g"), "🎤"); + + // Apply pronunciation fixes + cleaned = applyPronunciationFixes(cleaned); + + // Collapse whitespace + cleaned = cleaned.replace(/\s+/g, " ").trim(); + + return cleaned; +} + +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Synthesize speech from text using VibeVoice API + */ +export async function synthesizeSpeech( + text: string, + config: TTSConfig, +): Promise { + const url = config.url || "http://10.10.20.19:7861"; + const voice = config.voice || "en-Soother_woman"; + const format = config.format || "mp3"; + const speed = config.speed || 1.0; + const sampleRate = config.sampleRate || 22050; + + const cleanedText = cleanTextForTTS(text); + + log.info(`[MatrixTTS] Synthesizing: ${cleanedText.slice(0, 50)}...`); + log.info(`[MatrixTTS] Voice: ${voice}, Format: ${format}`); + + try { + const response = await fetch(`${url}/audio/speech`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: cleanedText, + voice: voice, + model: "vibevoice-v1", + }), + }); + + if (!response.ok) { + throw new Error(`TTS API error: ${response.status} ${response.statusText}`); + } + + const audioBuffer = Buffer.from(await response.arrayBuffer()); + log.info(`[MatrixTTS] Synthesized ${audioBuffer.length} bytes`); + + return audioBuffer; + } catch (err) { + log.error("[MatrixTTS] Failed to synthesize:", err); + throw err; + } +} + +/** + * Get available voices from VibeVoice API + */ +export async function getAvailableVoices(url?: string): Promise { + const apiUrl = url || "http://10.10.20.19:7861"; + + try { + const response = await fetch(`${apiUrl}/voices`); + if (!response.ok) { + throw new Error(`TTS API error: ${response.status} ${response.statusText}`); + } + + const voices = (await response.json()) as VoiceInfo[]; + log.info(`[MatrixTTS] Found ${voices.length} voices`); + return voices; + } catch (err) { + log.error("[MatrixTTS] Failed to get voices:", err); + // Return default voice info as fallback + return [{ id: "en_soothing", name: "Soothing English", language: "en" }]; + } +} diff --git a/src/channels/matrix/types.ts b/src/channels/matrix/types.ts new file mode 100644 index 0000000..79c5e25 --- /dev/null +++ b/src/channels/matrix/types.ts @@ -0,0 +1,195 @@ +/** + * Matrix Adapter Types + * + * Shared types, constants, and interfaces for the Matrix adapter. + */ + +import type { DmPolicy } from "../../pairing/types.js"; + +// Configuration interface (extends the base MatrixConfig from config/types.ts) +export interface MatrixAdapterConfig { + homeserverUrl: string; + userId: string; + accessToken?: string; + password?: string; + deviceId?: string; + + // Security + dmPolicy?: DmPolicy; + allowedUsers?: string[]; + selfChatMode?: boolean; + + // E2EE + enableEncryption?: boolean; + recoveryKey?: string; + userDeviceId?: string; // User's Element device ID for proactive verification + + // Storage + storeDir?: string; + sessionDir?: string; // Alias for storeDir (used by factory) + sessionFile?: string; + + // Features + transcriptionEnabled?: boolean; + sttUrl?: string; + ttsUrl?: string; + ttsVoice?: string; + enableAudioResponse?: boolean; + audioRoomFilter?: "dm_only" | "all" | "none"; + + // Image handling + imageMaxSize?: number; + + // File uploads — base directory for saving received files (aligns with shared attachmentsDir) + // Files saved to: {attachmentsDir}/uploads/YYYY-MM/{filename} + // Defaults to process.cwd() so agent Bash tools can access them + attachmentsDir?: string; + attachmentsMaxBytes?: number; + /** @deprecated use attachmentsDir */ + uploadDir?: string; + + // Reactions + enableReactions?: boolean; + + // Streaming edits + streaming?: boolean; + + // Auto-join rooms on invite + autoJoinRooms?: boolean; + + // Group batching settings + /** Debounce interval for group room messages in seconds (default: 5s, 0 = immediate) */ + groupDebounceSec?: number; + /** Room IDs that bypass debouncing entirely */ + instantGroups?: string[]; + /** Room IDs where bot listens but doesn't respond (observer mode) */ + listeningGroups?: string[]; + + // Message prefix for bot responses + messagePrefix?: string; + + // Storage pruning + enableStoragePruning?: boolean; + storageRetentionDays?: number; + storagePruningIntervalHours?: number; +} + +// Session type +export interface MatrixSession { + userId: string; + deviceId: string; + accessToken: string; + homeserver: string; + timestamp: string; +} + +// Message queue types +export interface QueueItem { + roomId: string; + sender: string; + message: string; + timestamp: number; + type: "text" | "audio" | "image"; + imageData?: { + data: Buffer; + format: string; + mimeType?: string; + }; +} + +// Pending image handling +export interface PendingImage { + eventId: string; + roomId: string; + imageData: Buffer; + format: string; + mimeType?: string; + timestamp: number; + message?: string; +} + +// Reaction definitions +export const POSITIVE_REACTIONS = new Set([ + "👍", + ":thumbsup:", + "❤️", + ":heart:", + "✅", + ":white_check_mark:", + "👏", + ":clap:", + "🎉", + ":tada:", + "🌟", + ":star:", +]); + +export const NEGATIVE_REACTIONS = new Set([ + "👎", + ":thumbsdown:", + "😢", + ":cry:", + "😔", + ":pensive:", + "❌", + ":x:", + "❎", + ":negative_squared_cross_mark:", + "😕", + ":confused:", +]); + +export const SPECIAL_REACTIONS = { + REGENERATE_AUDIO: "🎤", + SEND_PENDING_IMAGE: "✅", +} as const; + +// Color constants (Matrix extensions) +export const MATRIX_COLORS = { + RED: "#FF0000", + GREEN: "#00FF00", + BLUE: "#0000FF", + HOT_PINK: "#FF1493", + PURPLE: "#800080", + ORANGE: "#FFA500", + YELLOW: "#FFFF00", + CYAN: "#00FFFF", + WHITE: "#FFFFFF", + BLACK: "#000000", + GREY: "#808080", +} as const; + +// HTML formatting constants +export const MATRIX_HTML_FORMAT = "org.matrix.custom.html"; + +// Default values +export const DEFAULTS = { + TTS_VOICE: "en-Soother_woman", + AUDIO_ROOM_FILTER: "dm_only" as const, + IMAGE_MAX_SIZE: 2000, + ENABLE_REACTIONS: true, + ENABLE_ENCRYPTION: true, +}; + +// Event content types (inline definitions since SDK doesn't export them in v40+) +export interface ReactionEventContent { + "m.relates_to": { + rel_type: string; + event_id: string; + key: string; + }; +} + +export interface RoomMessageEventContent { + msgtype: string; + body: string; + format?: string; + formatted_body?: string; + url?: string; + info?: { + mimetype?: string; + size?: number; + w?: number; + h?: number; + }; +} diff --git a/src/channels/matrix/verification.ts b/src/channels/matrix/verification.ts new file mode 100644 index 0000000..2ababa5 --- /dev/null +++ b/src/channels/matrix/verification.ts @@ -0,0 +1,398 @@ +/** + * Matrix E2EE Device Verification Handler + * + * Handles SAS (emoji) device verification for matrix-js-sdk v28 with rust crypto. + * + * KEY FIXES: + * - Event handlers MUST be set up BEFORE startClient() + * - Use literal string event names: "show_sas", "cancel", "change" + * - Call verifier.verify() to actually start the verification flow + * - Accept when NOT in accepting state (!request.accepting) + */ + +import { createLogger } from "../../logger.js"; +import * as sdk from "matrix-js-sdk"; + +const log = createLogger('MatrixVerification'); + +interface VerificationCallbacks { + onShowSas?: (emojis: string[]) => void; + onComplete?: () => void; + onCancel?: (reason: string) => void; + onError?: (error: Error) => void; +} + +interface ActiveVerification { + userId: string; + deviceId: string; + verifier: sdk.Crypto.Verifier | null; + request: sdk.Crypto.VerificationRequest; + sasCallbacks?: sdk.Crypto.ShowSasCallbacks | null; +} + +/** + * Matrix Verification Handler for rust crypto backend + * + * Event flow (Matrix spec-compliant): + * 1. m.key.verification.request (incoming) + * 2. m.key.verification.ready (we accept) + * 3. m.key.verification.start (SAS method) + * 4. m.key.verification.key (exchange keys) + * 5. SAS computed - we call confirm() + * 6. m.key.verification.mac (send MAC) + * 7. m.key.verification.done + * + * CRITICAL: setupEventHandlers() MUST be called BEFORE client.startClient() + */ +export class MatrixVerificationHandler { + private client: sdk.MatrixClient; + private activeVerifications = new Map(); + private callbacks: VerificationCallbacks; + + constructor(client: sdk.MatrixClient, callbacks: VerificationCallbacks = {}) { + this.client = client; + this.callbacks = callbacks; + } + + /** + * CRITICAL: Call this BEFORE client.startClient() + */ + setupEventHandlers(): void { + // Log all verification to-device messages for debugging + this.client.on(sdk.ClientEvent.ToDeviceEvent, (event: sdk.MatrixEvent) => { + const type = event.getType(); + if (type.startsWith("m.key.verification")) { + log.info(`[MatrixVerification] To-device: ${type} from ${event.getSender()}`, event.getContent()); + } + }); + + // Listen for verification requests from rust crypto + // This is the PRIMARY event for incoming verification requests + this.client.on(sdk.CryptoEvent.VerificationRequestReceived, (request: sdk.Crypto.VerificationRequest) => { + log.info(`[MatrixVerification] VerificationRequestReceived: ${request.otherUserId}:${request.otherDeviceId}, phase=${this.phaseName(request.phase)}`); + this.handleVerificationRequest(request); + }); + + // Listen for device verification status changes + this.client.on(sdk.CryptoEvent.DevicesUpdated, (userIds: string[]) => { + log.info(`[MatrixVerification] Devices updated: ${userIds.join(", ")}`); + }); + + log.info("[MatrixVerification] Event handlers configured (ready BEFORE startClient())"); + } + + private phaseName(phase: sdk.Crypto.VerificationPhase): string { + const phases = ["Unsent", "Requested", "Ready", "Started", "Cancelled", "Done"]; + return phases[phase - 1] || `Unknown(${phase})`; + } + + private handleVerificationRequest(request: sdk.Crypto.VerificationRequest): void { + const otherUserId = request.otherUserId; + const otherDeviceId = request.otherDeviceId || "unknown"; + const key = `${otherUserId}|${otherDeviceId}`; + + // Check if already handling - but allow new requests if the old one is cancelled/timed out + const existing = this.activeVerifications.get(key); + if (existing) { + // If existing request is in a terminal state, clear it and proceed + if (existing.request.phase === sdk.Crypto.VerificationPhase.Cancelled || + existing.request.phase === sdk.Crypto.VerificationPhase.Done) { + log.info(`[MatrixVerification] Clearing stale verification: ${otherUserId}:${otherDeviceId}`); + this.activeVerifications.delete(key); + } else if (request.phase === sdk.Crypto.VerificationPhase.Requested) { + // New request coming in while old one pending - replace it + log.info(`[MatrixVerification] Replacing stale verification: ${otherUserId}:${otherDeviceId}`); + this.activeVerifications.delete(key); + } else { + log.info(`[MatrixVerification] Already handling: ${otherUserId}:${otherDeviceId}`); + return; + } + } + + log.info(`[MatrixVerification] *** REQUEST from ${otherUserId}:${otherDeviceId} ***`); + log.info(`[MatrixVerification] Phase: ${this.phaseName(request.phase)}`); + + // NOTE: request.methods throws "not implemented" for RustVerificationRequest + // Rust crypto with SAS uses m.sas.v1 method by default + + // Store the request immediately + this.activeVerifications.set(key, { + userId: otherUserId, + deviceId: otherDeviceId, + verifier: null, + request, + sasCallbacks: null, + }); + + // Handle based on phase + if (request.phase === sdk.Crypto.VerificationPhase.Requested) { + // Automatically accept incoming requests + this.acceptAndStartSAS(request, key); + } else if (request.phase === sdk.Crypto.VerificationPhase.Ready) { + // Already ready, start SAS + this.startSASVerification(request, key); + } else if (request.phase === sdk.Crypto.VerificationPhase.Started && request.verifier) { + // Verification already started, attach listeners + this.attachVerifierListeners(request.verifier, request, key); + } + } + + private async acceptAndStartSAS(request: sdk.Crypto.VerificationRequest, key: string): Promise { + try { + log.info("[MatrixVerification] Accepting verification request..."); + await request.accept(); + log.info(`[MatrixVerification] Accepted, phase is now: ${this.phaseName(request.phase)}`); + + // Check if already Ready (phase might change immediately) + if (request.phase === sdk.Crypto.VerificationPhase.Ready) { + log.info("[MatrixVerification] Already Ready, starting SAS immediately..."); + this.startSASVerification(request, key); + return; + } + + // The SDK will emit a 'change' event when phase changes to Ready + // Listen for that and then start SAS + const onChange = () => { + log.info(`[MatrixVerification] Phase changed to: ${this.phaseName(request.phase)}`); + if (request.phase === sdk.Crypto.VerificationPhase.Ready) { + log.info("[MatrixVerification] Now in Ready phase, starting SAS..."); + request.off("change" as any, onChange); + this.startSASVerification(request, key); + } else if (request.phase === sdk.Crypto.VerificationPhase.Done) { + request.off("change" as any, onChange); + } + }; + request.on("change" as any, onChange); + + // Also check after a short delay in case event doesn't fire + setTimeout(() => { + if (request.phase === sdk.Crypto.VerificationPhase.Ready) { + log.info("[MatrixVerification] Ready detected via timeout, starting SAS..."); + request.off("change" as any, onChange); + this.startSASVerification(request, key); + } + }, 1000); + } catch (err) { + log.error("[MatrixVerification] Failed to accept:", err); + this.callbacks.onError?.(err as Error); + } + } + + private async startSASVerification(request: sdk.Crypto.VerificationRequest, key: string): Promise { + try { + log.info("[MatrixVerification] Starting SAS verification with m.sas.v1..."); + + // CRITICAL: Fetch device keys for the other user BEFORE starting SAS + // Without this, rust crypto says "device doesn't exist" + const crypto = this.client.getCrypto(); + if (crypto && request.otherUserId) { + log.info(`[MatrixVerification] Fetching device keys for ${request.otherUserId}...`); + await crypto.getUserDeviceInfo([request.otherUserId], true); + log.info("[MatrixVerification] Device keys fetched"); + // Small delay to let the crypto module process the keys + await new Promise(resolve => setTimeout(resolve, 500)); + } + + // Check if verifier already exists + const existingVerifier = request.verifier; + log.info(`[MatrixVerification] Verifier exists: ${!!existingVerifier}`); + + if (existingVerifier) { + log.info("[MatrixVerification] Verifier already exists, attaching listeners..."); + this.attachVerifierListeners(existingVerifier, request, key); + return; + } + + log.info("[MatrixVerification] Calling request.startVerification()..."); + // Start the SAS verification + const verifier = await request.startVerification("m.sas.v1"); + + log.info(`[MatrixVerification] startVerification() returned: ${!!verifier}`); + + if (!verifier) { + throw new Error("startVerification returned undefined"); + } + + log.info("[MatrixVerification] SAS verifier created"); + + // Update stored verification + const stored = this.activeVerifications.get(key); + if (stored) { + stored.verifier = verifier; + } + + // Attach listeners + log.info("[MatrixVerification] Attaching verifier listeners..."); + this.attachVerifierListeners(verifier, request, key); + + log.info("[MatrixVerification] Calling verifier.verify()..."); + // Start the verification flow - this sends the accept message + await verifier.verify(); + + log.info("[MatrixVerification] verifier.verify() completed successfully"); + + } catch (err) { + log.error("[MatrixVerification] Error starting SAS:", err); + this.callbacks.onError?.(err as Error); + } + } + + private attachVerifierListeners(verifier: sdk.Crypto.Verifier, request: sdk.Crypto.VerificationRequest, key: string): void { + // CRITICAL: Use the literal string "show_sas", not an enum property + verifier.on("show_sas" as any, (sas: sdk.Crypto.ShowSasCallbacks) => { + log.info("[MatrixVerification] *** SHOW SAS (EMOJI) ***"); + + if (!sas) { + log.error("[MatrixVerification] No SAS data received!"); + return; + } + + const sasData = verifier.getShowSasCallbacks(); + if (!sasData?.sas?.emoji) { + log.error("[MatrixVerification] No emoji data in SAS!"); + return; + } + + const emojis = sasData.sas.emoji.map((e: [string, string]) => `${e[0]} ${e[1]}`); + log.info("[MatrixVerification] Emojis:", emojis.join(" | ")); + log.info("[MatrixVerification] *** COMPARE THESE EMOJIS IN ELEMENT ***"); + + // Store callbacks and notify user + const stored = this.activeVerifications.get(key); + if (stored) { + stored.sasCallbacks = sasData; + } + + this.callbacks.onShowSas?.(emojis); + + // Auto-confirm after delay for bot + setTimeout(() => { + this.confirmVerification(key); + }, 5000); // 5 seconds for emoji comparison + }); + + // CRITICAL: Use the literal string "cancel" + verifier.on("cancel" as any, (err: Error | sdk.MatrixEvent) => { + log.error("[MatrixVerification] Verification cancelled:", err); + this.activeVerifications.delete(key); + + const reason = err instanceof Error ? err.message : "Verification cancelled"; + this.callbacks.onCancel?.(reason); + }); + + // Listen for verification request phase changes + request.on("change" as any, () => { + const phase = request.phase; + log.info(`[MatrixVerification] Request phase changed: ${this.phaseName(phase)}`); + + if (phase === sdk.Crypto.VerificationPhase.Done) { + log.info("[MatrixVerification] *** VERIFICATION DONE ***"); + this.activeVerifications.delete(key); + this.callbacks.onComplete?.(); + } else if (phase === sdk.Crypto.VerificationPhase.Cancelled) { + log.info("[MatrixVerification] *** VERIFICATION CANCELLED ***"); + this.activeVerifications.delete(key); + this.callbacks.onCancel?.(request.cancellationCode || "Unknown"); + } + }); + } + + async confirmVerification(key: string): Promise { + const stored = this.activeVerifications.get(key); + if (!stored?.sasCallbacks) { + log.info("[MatrixVerification] No pending verification to confirm"); + return; + } + + log.info("[MatrixVerification] Confirming verification (sending MAC)..."); + try { + await stored.sasCallbacks.confirm(); + log.info("[MatrixVerification] Verification confirmed (MAC sent). Waiting for Done..."); + } catch (err) { + log.error("[MatrixVerification] Failed to confirm:", err); + this.callbacks.onError?.(err as Error); + } + } + + /** + * Request verification with a specific device (initiated by us) + */ + async requestVerification(userId: string, deviceId: string): Promise { + const crypto = this.client.getCrypto(); + if (!crypto) { + throw new Error("Crypto not initialized"); + } + + log.info(`[MatrixVerification] Requesting verification with ${userId}:${deviceId}`); + + const request = await crypto.requestDeviceVerification(userId, deviceId); + const key = `${userId}|${deviceId}`; + + this.activeVerifications.set(key, { + userId, + deviceId, + verifier: null, + request, + }); + + // Listen for the request to be ready, then start SAS + const onReadyOrStarted = () => { + const phase = request.phase; + if (phase === sdk.Crypto.VerificationPhase.Ready) { + log.info("[MatrixVerification] Outgoing request ready, starting SAS..."); + this.startSASVerification(request, key); + request.off("change" as any, onReadyOrStarted); + } else if (phase === sdk.Crypto.VerificationPhase.Started && request.verifier) { + log.info("[MatrixVerification] Outgoing request already started, attaching listeners..."); + this.attachVerifierListeners(request.verifier, request, key); + request.off("change" as any, onReadyOrStarted); + } + }; + request.on("change" as any, onReadyOrStarted); + + return request; + } + + /** + * Get all pending verification requests for a user + */ + getVerificationRequests(userId: string): sdk.Crypto.VerificationRequest[] { + const requests: sdk.Crypto.VerificationRequest[] = []; + for (const [key, value] of Array.from(this.activeVerifications.entries())) { + if (key.startsWith(`${userId}|`)) { + requests.push(value.request); + } + } + return requests; + } + + dispose(): void { + this.activeVerifications.forEach((v) => { + try { + // Note: EventEmitter.off() requires the specific handler reference + // Since we used anonymous functions, we can't easily remove them + // The map clear below will allow garbage collection anyway + } catch (e) { + // Ignore cleanup errors + } + }); + this.activeVerifications.clear(); + } +} + +/** + * Format emojis for display + */ +export function formatEmojis(emojis: unknown[]): string { + if (!Array.isArray(emojis)) return ""; + + return emojis + .map((e) => { + if (Array.isArray(e) && e.length >= 2) { + return `${e[0]} ${e[1]}`; + } + return ""; + }) + .filter(Boolean) + .join(" | "); +} diff --git a/src/channels/setup.ts b/src/channels/setup.ts index 055dcad..974c8d3 100644 --- a/src/channels/setup.ts +++ b/src/channels/setup.ts @@ -20,9 +20,10 @@ export const CHANNELS = [ { id: 'whatsapp', displayName: 'WhatsApp', hint: 'QR code pairing' }, { id: 'signal', displayName: 'Signal', hint: 'signal-cli daemon' }, { id: 'bluesky', displayName: 'Bluesky', hint: 'Jetstream feed (read-only)' }, + { id: 'matrix', displayName: 'Matrix', hint: 'E2EE support with Element' }, ] as const; -export type ChannelId = typeof CHANNELS[number]['id']; +export type ChannelId = typeof CHANNELS[number]['id'] | 'telegram-mtproto' | 'mock'; export function getChannelMeta(id: ChannelId) { return CHANNELS.find(c => c.id === id)!; @@ -43,7 +44,7 @@ export function getChannelHint(id: ChannelId): string { // Group ID hints per channel // ============================================================================ -const GROUP_ID_HINTS: Record = { +const GROUP_ID_HINTS: Record = { telegram: 'Group IDs are negative numbers (e.g., -1001234567890).\n' + 'Forward a group message to @userinfobot, or check bot logs.', @@ -60,6 +61,9 @@ const GROUP_ID_HINTS: Record = { 'Group IDs appear in bot logs on first group message.', bluesky: 'Bluesky does not support groups. This setting is not used.', + matrix: + 'Room IDs are in the format: !room:server.com\n' + + 'In Element: Room Settings > Advanced > Room ID', }; // ============================================================================ @@ -152,7 +156,7 @@ async function promptGroupSettings( } // Step 3: Channel-specific hint for finding group IDs - const hint = GROUP_ID_HINTS[channelId]; + const hint = (GROUP_ID_HINTS as any)[channelId]; if (hint && mode !== 'disabled') { p.note( hint + '\n\n' + @@ -718,15 +722,31 @@ export async function setupBluesky(existing?: BlueskyConfig): Promise { + p.note( + 'Matrix setup is extensive. Please configure lettabot.yaml manually.\n' + + 'See docs/matrix-setup.md for detailed instructions.', + 'Matrix Setup' + ); + return existing || {}; +} + /** Get the setup function for a channel */ export function getSetupFunction(id: ChannelId): (existing?: any) => Promise { - const setupFunctions: Record Promise> = { + // Only setup channels that appear in CHANNELS array + const setupChannel = CHANNELS.find(c => c.id === id); + if (!setupChannel) { + throw new Error(`Channel '${id}' does not have a setup function`); + } + + const setupFunctions: Record Promise> = { telegram: setupTelegram, slack: setupSlack, discord: setupDiscord, whatsapp: setupWhatsApp, signal: setupSignal, bluesky: setupBluesky, + matrix: setupMatrix, }; - return setupFunctions[id]; + return setupFunctions[id as typeof CHANNELS[number]['id']]; } diff --git a/src/channels/types.ts b/src/channels/types.ts index 863891b..006f20b 100644 --- a/src/channels/types.ts +++ b/src/channels/types.ts @@ -4,7 +4,8 @@ * Each channel (Telegram, Slack, Discord, WhatsApp, Signal) implements this interface. */ -import type { ChannelId, InboundMessage, OutboundMessage, OutboundFile, FormatterHints } from '../core/types.js'; +import type { InboundMessage, OutboundMessage, OutboundFile, FormatterHints } from '../core/types.js'; +import type { ChannelId } from './setup.js'; /** * Channel adapter - implement this for each messaging platform @@ -20,20 +21,27 @@ export interface ChannelAdapter { // Messaging sendMessage(msg: OutboundMessage): Promise<{ messageId: string }>; - editMessage(chatId: string, messageId: string, text: string): Promise; + editMessage(chatId: string, messageId: string, text: string, htmlPrefix?: string): Promise; sendTypingIndicator(chatId: string): Promise; stopTypingIndicator?(chatId: string): Promise; // Capabilities (optional) supportsEditing?(): boolean; sendFile?(file: OutboundFile): Promise<{ messageId: string }>; + sendAudio?(chatId: string, text: string): Promise; addReaction?(chatId: string, messageId: string, emoji: string): Promise; + removeReaction?(chatId: string, messageId: string, emoji: string): Promise; + /** Called after a bot message is sent (for TTS mapping, etc.) */ + onMessageSent?(chatId: string, messageId: string, stepId?: string): void; + /** Store text for TTS regeneration on 🎤 reaction */ + storeAudioMessage?(messageId: string, conversationId: string, roomId: string, text: string): void; getDmPolicy?(): string; getFormatterHints(): FormatterHints; // Event handlers (set by bot core) onMessage?: (msg: InboundMessage) => Promise; onCommand?: (command: string, chatId?: string, args?: string, forcePerChat?: boolean) => Promise; + onInvalidateSession?: (key?: string) => void; } /** diff --git a/src/cli/channel-management.ts b/src/cli/channel-management.ts index d9ee62f..43aa0c3 100644 --- a/src/cli/channel-management.ts +++ b/src/cli/channel-management.ts @@ -7,11 +7,11 @@ import * as p from '@clack/prompts'; import { loadAppConfigOrExit, saveConfig, resolveConfigPath } from '../config/index.js'; -import { - CHANNELS, - getChannelHint, +import { + CHANNELS, + getChannelHint, getSetupFunction, - type ChannelId + type ChannelId } from '../channels/setup.js'; import { listGroupsFromArgs } from './group-listing.js'; @@ -201,7 +201,7 @@ export async function addChannel(channelId?: string): Promise { } const channelIds = CHANNELS.map(c => c.id); - if (!channelIds.includes(channelId as ChannelId)) { + if (!channelIds.includes(channelId as typeof CHANNELS[number]['id'])) { console.error(`Unknown channel: ${channelId}`); console.error(`Valid channels: ${channelIds.join(', ')}`); process.exit(1); @@ -229,7 +229,7 @@ export async function removeChannel(channelId?: string): Promise { process.exit(1); } - if (!channelIds.includes(channelId as ChannelId)) { + if (!channelIds.includes(channelId as typeof CHANNELS[number]['id'])) { console.error(`Unknown channel: ${channelId}`); console.error(`Valid channels: ${channelIds.join(', ')}`); process.exit(1); diff --git a/src/cli/config-tui.ts b/src/cli/config-tui.ts index 51a4a74..dc58e92 100644 --- a/src/cli/config-tui.ts +++ b/src/cli/config-tui.ts @@ -152,7 +152,7 @@ function isChannelEnabled(config: unknown): boolean { function getEnabledChannelIds(channels: AgentConfig['channels']): ChannelId[] { return CHANNELS .map((channel) => channel.id) - .filter((channelId) => isChannelEnabled(channels[channelId])); + .filter((channelId) => isChannelEnabled((channels as any)[channelId])); } export function getCoreDraftWarnings(draft: CoreConfigDraft): string[] { @@ -262,6 +262,11 @@ async function editAgent(draft: CoreConfigDraft): Promise { } async function runChannelSetupSafely(channelId: ChannelId, existing?: unknown): Promise { + // Only setup channels that appear in CHANNELS array + const channelIds = CHANNELS.map(c => c.id); + if (!channelIds.includes(channelId as typeof CHANNELS[number]['id'])) { + return undefined; + } const setup = getSetupFunction(channelId); const originalExit = process.exit; @@ -283,7 +288,7 @@ async function runChannelSetupSafely(channelId: ChannelId, existing?: unknown): } async function configureChannel(draft: CoreConfigDraft, channelId: ChannelId): Promise { - const current = draft.channels[channelId]; + const current = (draft.channels as any)[channelId]; const enabled = isChannelEnabled(current); const action = await p.select({ @@ -308,7 +313,7 @@ async function configureChannel(draft: CoreConfigDraft, channelId: ChannelId): P initialValue: false, }); if (p.isCancel(confirmed) || !confirmed) return; - draft.channels[channelId] = { enabled: false } as AgentConfig['channels'][ChannelId]; + ;(draft.channels as any)[channelId] = { enabled: false }; return; } @@ -317,7 +322,7 @@ async function configureChannel(draft: CoreConfigDraft, channelId: ChannelId): P p.log.info(`${channelId} setup cancelled.`); return; } - draft.channels[channelId] = result as AgentConfig['channels'][ChannelId]; + ;(draft.channels as any)[channelId] = result as any; } async function editChannels(draft: CoreConfigDraft): Promise { @@ -326,7 +331,7 @@ async function editChannels(draft: CoreConfigDraft): Promise { message: 'Select a channel to edit', options: [ ...CHANNELS.map((channel) => { - const enabled = isChannelEnabled(draft.channels[channel.id]); + const enabled = isChannelEnabled((draft.channels as any)[channel.id]); return { value: channel.id, label: `${enabled ? '✓' : '✗'} ${channel.displayName}`, diff --git a/src/config/types.ts b/src/config/types.ts index 2d4b0eb..d8b2a4b 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -39,6 +39,10 @@ export interface DisplayConfig { showReasoning?: boolean; /** Truncate reasoning to N characters (default: 0 = no limit) */ reasoningMaxChars?: number; + /** Room IDs where reasoning should be shown (empty = all rooms that have showReasoning) */ + reasoningRooms?: string[]; + /** Room IDs where reasoning should be hidden (takes precedence over reasoningRooms) */ + noReasoningRooms?: string[]; } export type SleeptimeTrigger = 'off' | 'step-count' | 'compaction-event'; @@ -74,6 +78,7 @@ export interface AgentConfig { signal?: SignalConfig; discord?: DiscordConfig; bluesky?: BlueskyConfig; + matrix?: MatrixConfig; }; /** Conversation routing */ conversations?: { @@ -82,6 +87,7 @@ export interface AgentConfig { perChannel?: string[]; // Channels that should always have their own conversation maxSessions?: number; // Max concurrent sessions in per-chat mode (default: 10, LRU eviction) reuseSession?: boolean; // Reuse SDK subprocess across messages (default: true). Set false to eliminate stream state bleed. + sessionModel?: string; // Model override for session creation (e.g., "synthetic-direct/hf:moonshotai/Kimi-K2.5") }; /** Features for this agent */ features?: { @@ -432,6 +438,69 @@ export interface BlueskyNotificationsConfig { backfill?: boolean; // Process unread notifications on startup (default: false) } +/** + * Matrix configuration. + * Supports end-to-end encryption (E2EE) with Element client. + */ +export interface MatrixConfig { + enabled: boolean; + homeserverUrl: string; + userId: string; + accessToken?: string; + password?: string; + deviceId?: string; + + // Security + dmPolicy?: 'pairing' | 'allowlist' | 'open'; + allowedUsers?: string[]; + selfChatMode?: boolean; + + // E2EE + enableEncryption?: boolean; + recoveryKey?: string; + userDeviceId?: string; // User's Element device ID for proactive verification + + // Storage + storeDir?: string; + sessionDir?: string; // Session directory for Matrix client + + // Auto-join rooms on startup + autoJoinRooms?: boolean; + + // Groups + groups?: Record; + + // TTS/STT (voice message) configuration + /** Enable voice message transcription (STT) */ + transcriptionEnabled?: boolean; + /** STT server URL for voice transcription */ + sttUrl?: string; + /** TTS server URL for outbound voice memos */ + ttsUrl?: string; + /** TTS voice ID/name */ + ttsVoice?: string; + /** Enable audio responses (TTS) */ + enableAudioResponse?: boolean; + /** Filter for which rooms get audio responses */ + audioRoomFilter?: 'dm_only' | 'all' | 'none'; + + // Media settings + /** Maximum image dimension before resize (default: 1024) */ + imageMaxSize?: number; + /** Prefix prepended to every outbound message */ + messagePrefix?: string; + /** Enable live streaming edits (default: true) */ + streaming?: boolean; + + // Group batching settings + /** Debounce interval for group room messages in seconds (default: 5s, 0 = immediate) */ + groupDebounceSec?: number; + /** Room IDs that bypass debouncing entirely */ + instantGroups?: string[]; + /** Room IDs where bot listens but doesn't respond (observer mode) */ + listeningGroups?: string[]; +} + /** * Telegram MTProto (user account) configuration. * Uses TDLib for user account mode instead of Bot API. @@ -609,6 +678,18 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { channels['telegram-mtproto'].phoneNumber = process.env.TELEGRAM_PHONE_NUMBER; } } + // Matrix TTS/STT env var merging + if (channels.matrix) { + if (!channels.matrix.ttsUrl && process.env.MATRIX_TTS_URL) channels.matrix.ttsUrl = process.env.MATRIX_TTS_URL; + if (!channels.matrix.ttsVoice && process.env.MATRIX_TTS_VOICE) channels.matrix.ttsVoice = process.env.MATRIX_TTS_VOICE; + if (!channels.matrix.sttUrl && process.env.MATRIX_STT_URL) channels.matrix.sttUrl = process.env.MATRIX_STT_URL; + if (channels.matrix.transcriptionEnabled === undefined && process.env.MATRIX_TRANSCRIPTION_ENABLED) { + channels.matrix.transcriptionEnabled = process.env.MATRIX_TRANSCRIPTION_ENABLED === 'true'; + } + if (channels.matrix.enableAudioResponse === undefined && process.env.MATRIX_ENABLE_AUDIO_RESPONSE) { + channels.matrix.enableAudioResponse = process.env.MATRIX_ENABLE_AUDIO_RESPONSE === 'true'; + } + } if (channels.telegram?.enabled !== false && channels.telegram?.token) { const telegram = { ...channels.telegram }; @@ -654,6 +735,12 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { normalized.bluesky = bluesky; } } + // Matrix: requires homeserverUrl and userId as credentials + if (channels.matrix?.enabled !== false && channels.matrix?.homeserverUrl && channels.matrix?.userId) { + const matrix = { ...channels.matrix }; + normalizeLegacyGroupFields(matrix, `${sourcePath}.matrix`); + normalized.matrix = matrix; + } const channelCredentials: Array<{ name: string; raw: unknown; included: boolean; required: string }> = [ { name: 'telegram', raw: channels.telegram, included: !!normalized.telegram, required: 'token' }, @@ -661,6 +748,7 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { { name: 'slack', raw: channels.slack, included: !!normalized.slack, required: 'botToken, appToken' }, { name: 'signal', raw: channels.signal, included: !!normalized.signal, required: 'phone' }, { name: 'discord', raw: channels.discord, included: !!normalized.discord, required: 'token' }, + { name: 'matrix', raw: channels.matrix, included: !!normalized.matrix, required: 'userId, password' }, ]; const invalidChannels = channelCredentials @@ -794,6 +882,27 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { : undefined, }; } + if (!channels.matrix && process.env.MATRIX_HOMESERVER_URL && process.env.MATRIX_USER_ID) { + channels.matrix = { + enabled: true, + homeserverUrl: process.env.MATRIX_HOMESERVER_URL, + userId: process.env.MATRIX_USER_ID, + accessToken: process.env.MATRIX_ACCESS_TOKEN, + password: process.env.MATRIX_PASSWORD, + deviceId: process.env.MATRIX_DEVICE_ID, + dmPolicy: (process.env.MATRIX_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing', + allowedUsers: parseList(process.env.MATRIX_ALLOWED_USERS), + selfChatMode: process.env.MATRIX_SELF_CHAT_MODE === 'true', + enableEncryption: process.env.MATRIX_ENABLE_ENCRYPTION === 'true', + // TTS/STT fields + ttsUrl: process.env.MATRIX_TTS_URL, + ttsVoice: process.env.MATRIX_TTS_VOICE, + sttUrl: process.env.MATRIX_STT_URL, + transcriptionEnabled: process.env.MATRIX_TRANSCRIPTION_ENABLED === 'true', + enableAudioResponse: process.env.MATRIX_ENABLE_AUDIO_RESPONSE === 'true', + audioRoomFilter: (process.env.MATRIX_AUDIO_ROOM_FILTER as 'dm_only' | 'all' | 'none') || 'dm_only', + }; + } // Field-level env var fallback for features (heartbeat, cron). // Unlike channels (all-or-nothing), features are independent toggles so we diff --git a/src/core/bot.ts b/src/core/bot.ts index 86024e5..d7df8b9 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -13,7 +13,7 @@ import { extname, resolve, join } from 'node:path'; import type { ChannelAdapter } from '../channels/types.js'; import type { BotConfig, InboundMessage, TriggerContext, TriggerType, StreamMsg } from './types.js'; import { formatApiErrorForUser } from './errors.js'; -import { formatToolCallDisplay, formatReasoningDisplay, formatQuestionsForChannel } from './display.js'; +import { formatToolCallDisplay, formatQuestionsForChannel, formatReasoningAsCodeBlock } from './display.js'; import type { AgentSession } from './interfaces.js'; import { Store } from './store.js'; import { getPendingApprovals, rejectApproval, cancelRuns, cancelConversation, recoverOrphanedConversationApproval, getLatestRunError, getAgentModel, updateAgentModel, isRecoverableConversationId, recoverPendingApprovalsForAgent } from '../tools/letta-api.js'; @@ -220,8 +220,11 @@ export function resolveConversationKey( conversationOverrides: Set, chatId?: string, forcePerChat?: boolean, + heartbeatTargetChatId?: string, ): string { if (conversationMode === 'disabled') return 'default'; + // Messages in the heartbeat target room share the heartbeat conversation + if (heartbeatTargetChatId && chatId === heartbeatTargetChatId) return 'heartbeat'; const normalized = channel.toLowerCase(); if ((conversationMode === 'per-chat' || forcePerChat) && chatId) return `${normalized}:${chatId}`; if (conversationMode === 'per-channel') return normalized; @@ -401,15 +404,17 @@ export class LettaBot implements AgentSession { let acted = false; for (const directive of directives) { if (directive.type === 'react') { + // Skip 👀 eyes emoji — it's handled as a receipt indicator, not a directive target + const resolved = resolveEmoji(directive.emoji); + if (resolved.unicode === '👀') { + continue; + } const targetId = directive.messageId || fallbackMessageId; if (!adapter.addReaction) { log.warn(`Directive react skipped: ${adapter.name} does not support addReaction`); continue; } if (targetId) { - // Resolve text aliases (thumbsup, eyes, etc.) to Unicode characters. - // The LLM typically outputs names; channel APIs need actual emoji. - const resolved = resolveEmoji(directive.emoji); try { await adapter.addReaction(chatId, targetId, resolved.unicode); acted = true; @@ -639,7 +644,7 @@ export class LettaBot implements AgentSession { * Returns channel id in per-channel mode or for override channels. */ private resolveConversationKey(channel: string, chatId?: string, forcePerChat?: boolean): string { - return resolveConversationKey(channel, this.config.conversationMode, this.conversationOverrides, chatId, forcePerChat); + return resolveConversationKey(channel, this.config.conversationMode, this.conversationOverrides, chatId, forcePerChat, this.config.heartbeatTargetChatId); } /** @@ -1311,6 +1316,30 @@ export class LettaBot implements AgentSession { let lastEventType: string | null = null; let abortedWithMessage = false; let turnError: string | undefined; + let collectedReasoning = ''; + + // ── Reaction tracking ── + // 👀 = receipt indicator (bot saw the message); removed when reasoning/tools start + // 🧠 = reasoning is happening; fires on first reasoning event + // Tool emojis = per-tool type indicators (max 6, deduplicated) + // 🎤 = on bot's sent message (tap to regenerate TTS) + let eyesAdded = false; + let brainAdded = false; + if (!suppressDelivery && msg.messageId) { + adapter.addReaction?.(msg.chatId, msg.messageId, '👀').catch(() => {}); + eyesAdded = true; + } + const seenToolEmojis = new Set(); + const getToolEmoji = (toolName: string): string => { + const n = toolName.toLowerCase(); + if (n.includes('search') || n.includes('web') || n.includes('browse')) return '🔍'; + if (n.includes('read') || n.includes('get') || n.includes('fetch') || n.includes('retrieve') || n.includes('recall')) return '📖'; + if (n.includes('write') || n.includes('send') || n.includes('create') || n.includes('post') || n.includes('insert')) return '✍️'; + if (n.includes('memory') || n.includes('archival')) return '💾'; + if (n.includes('shell') || n.includes('bash') || n.includes('exec') || n.includes('run') || n.includes('code') || n.includes('terminal')) return '⚙️'; + if (n.includes('image') || n.includes('vision') || n.includes('photo')) return '📸'; + return '🔧'; + }; const parseAndHandleDirectives = async () => { if (!response.trim()) return; @@ -1402,20 +1431,23 @@ export class LettaBot implements AgentSession { } lastEventType = 'reasoning'; sawNonAssistantSinceLastUuid = true; - if (this.config.display?.showReasoning && !suppressDelivery && event.content.trim()) { - log.info(`Reasoning: ${event.content.trim().slice(0, 100)}`); - try { - const reasoning = formatReasoningDisplay(event.content, adapter.id, this.config.display?.reasoningMaxChars); - await adapter.sendMessage({ - chatId: msg.chatId, - text: reasoning.text, - threadId: msg.threadId, - parseMode: reasoning.parseMode, - }); - } catch (err) { - log.warn('Failed to send reasoning display:', err instanceof Error ? err.message : err); - } + // Collect reasoning for later prepending to final response + if (event.content) { + collectedReasoning += event.content; } + + // Remove 👀 on first reasoning event (replaced by 🧠) + if (eyesAdded && msg.messageId) { + adapter.removeReaction?.(msg.chatId, msg.messageId, '👀').catch(() => {}); + eyesAdded = false; + } + // Add 🧠 on first reasoning event (once only — Matrix toggles on duplicate) + if (!brainAdded && !suppressDelivery && msg.messageId) { + adapter.addReaction?.(msg.chatId, msg.messageId, '🧠').catch(() => {}); + brainAdded = true; + } + // Note: reasoning is now collected and prepended to final response + // instead of being sent as a separate message break; } @@ -1429,6 +1461,20 @@ export class LettaBot implements AgentSession { log.info(`>>> TOOL CALL: ${event.name} (id: ${event.id.slice(0, 12) || '?'})`); sawNonAssistantSinceLastUuid = true; + // Remove 👀 on first tool event (replaced by tool emoji) + if (eyesAdded && msg.messageId) { + adapter.removeReaction?.(msg.chatId, msg.messageId, '👀').catch(() => {}); + eyesAdded = false; + } + // Add per-tool emoji (fire-and-forget, max 6 deduplicated) + if (!suppressDelivery && msg.messageId) { + const emoji = getToolEmoji(event.name); + if (!seenToolEmojis.has(emoji) && seenToolEmojis.size < 6) { + seenToolEmojis.add(emoji); + adapter.addReaction?.(msg.chatId, msg.messageId, emoji).catch(() => {}); + } + } + // Tool loop detection const maxToolCalls = this.config.maxToolCalls ?? 100; if ((msgTypeCounts['tool_call'] || 0) >= maxToolCalls) { @@ -1529,7 +1575,7 @@ export class LettaBot implements AgentSession { || hasUnclosedActionsBlock(response); const streamText = stripActionsBlock(response).trim(); if (canEdit && !mayBeHidden && !suppressDelivery && !this.cancelledKeys.has(convKey) - && streamText.length > 0 && Date.now() - lastUpdate > 1500 && Date.now() > rateLimitedUntil) { + && streamText.length > 0 && Date.now() - lastUpdate > 800 && Date.now() > rateLimitedUntil) { try { const prefixedStream = this.prefixResponse(streamText); if (messageId) { @@ -1750,6 +1796,12 @@ export class LettaBot implements AgentSession { } lap('stream complete'); + // Remove 👀 if still present (stream had only assistant chunks, no reasoning/tools) + if (eyesAdded && msg.messageId) { + adapter.removeReaction?.(msg.chatId, msg.messageId, '👀').catch(() => {}); + eyesAdded = false; + } + // If cancelled, clean up partial state and return early if (this.cancelledKeys.has(convKey)) { if (messageId) { @@ -1804,25 +1856,66 @@ export class LettaBot implements AgentSession { log.info(`Waiting ${(waitMs / 1000).toFixed(1)}s for rate limit before final send`); await new Promise(resolve => setTimeout(resolve, waitMs)); } - const prefixedFinal = this.prefixResponse(response); + + // Determine if reasoning should be shown for this room + const chatId = msg.chatId; + const noReasoningRooms = this.config.display?.noReasoningRooms || []; + const reasoningRooms = this.config.display?.reasoningRooms; + const shouldShowReasoning = this.config.display?.showReasoning && + !noReasoningRooms.includes(chatId) && + (!reasoningRooms || reasoningRooms.length === 0 || reasoningRooms.includes(chatId)); + + // Build reasoning HTML prefix if available (injected into formatted_body only) + let reasoningHtmlPrefix: string | undefined; + if (collectedReasoning.trim() && shouldShowReasoning) { + const reasoningBlock = formatReasoningAsCodeBlock( + collectedReasoning, + adapter.id, + this.config.display?.reasoningMaxChars + ); + if (reasoningBlock) { + reasoningHtmlPrefix = reasoningBlock.text; + log.info(`Reasoning block generated (${reasoningHtmlPrefix.length} chars) for ${chatId}`); + } + } + + const finalResponse = this.prefixResponse(response); + try { if (messageId) { - await adapter.editMessage(msg.chatId, messageId, prefixedFinal); + await adapter.editMessage(msg.chatId, messageId, finalResponse, reasoningHtmlPrefix); } else { - await adapter.sendMessage({ chatId: msg.chatId, text: prefixedFinal, threadId: msg.threadId }); + await adapter.sendMessage({ chatId: msg.chatId, text: finalResponse, threadId: msg.threadId, htmlPrefix: reasoningHtmlPrefix }); } sentAnyMessage = true; this.store.resetRecoveryAttempts(); } catch (sendErr) { log.warn('Final message delivery failed:', sendErr instanceof Error ? sendErr.message : sendErr); try { - await adapter.sendMessage({ chatId: msg.chatId, text: prefixedFinal, threadId: msg.threadId }); + const result = await adapter.sendMessage({ chatId: msg.chatId, text: finalResponse, threadId: msg.threadId, htmlPrefix: reasoningHtmlPrefix }); + messageId = result.messageId ?? null; sentAnyMessage = true; this.store.resetRecoveryAttempts(); } catch (retryError) { log.error('Retry send also failed:', retryError); } } + + // Post-response: 🎤 on bot's message + TTS audio (non-blocking) + // Tool/reasoning reactions already fired on USER's message during stream. + if (sentAnyMessage && messageId) { + adapter.onMessageSent?.(msg.chatId, messageId); + // 🎤 on bot's TEXT message (tap to regenerate TTS audio) + adapter.addReaction?.(msg.chatId, messageId, '🎤').catch(() => {}); + // Store raw text — adapter's TTS layer will clean it at synthesis time + adapter.storeAudioMessage?.(messageId, 'default', msg.chatId, response); + // Generate TTS audio only in response to voice input + if (msg.isVoiceInput) { + adapter.sendAudio?.(msg.chatId, response).catch((err) => { + log.warn('TTS failed (non-fatal):', err); + }); + } + } } lap('message delivered'); diff --git a/src/core/display.ts b/src/core/display.ts index ecae3cd..53a21c8 100644 --- a/src/core/display.ts +++ b/src/core/display.ts @@ -255,6 +255,36 @@ export function formatReasoningDisplay( return { text: `> **Thinking**\n${quoted}` }; } +/** + * Format reasoning as a collapsible
    block for prepending to the response. + * Returns pre-escaped HTML meant to be injected directly into formatted_body + * (bypasses the adapter's markdown-to-HTML conversion to avoid double-escaping). + */ +export function formatReasoningAsCodeBlock( + text: string, + channelId?: string, + reasoningMaxChars?: number, +): { text: string } | null { + const maxChars = reasoningMaxChars ?? 0; + const cleaned = text.split('\n').map(line => line.trimStart()).join('\n').trim(); + if (!cleaned) return null; + + const truncated = maxChars > 0 && cleaned.length > maxChars + ? cleaned.slice(0, maxChars) + '...' + : cleaned; + + // HTML-escape the reasoning content, then convert newlines to
    + const escaped = truncated + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
    '); + + return { + text: `
    🧠 Thinking
    ${escaped}

    `, + }; +} + /** * Format AskUserQuestion options for channel display. */ diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts index 74bbf85..b48a563 100644 --- a/src/core/gateway.test.ts +++ b/src/core/gateway.test.ts @@ -17,6 +17,7 @@ function createMockSession(channels: string[] = ['telegram']): AgentSession { reset: vi.fn(), getLastMessageTarget: vi.fn().mockReturnValue(null), getLastUserMessageTime: vi.fn().mockReturnValue(null), + invalidateSession: vi.fn(), }; } diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index 7717b3f..c0ec8b6 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -57,6 +57,12 @@ export interface AgentSession { /** Callback to trigger heartbeat */ onTriggerHeartbeat?: () => Promise; + + /** Invalidate a cached session, forcing fresh session on next message */ + invalidateSession(key?: string): void; + + /** Callback for session invalidation requests (e.g., from !new command) */ + onInvalidateSession?: (key?: string) => void; } /** diff --git a/src/core/prompts.ts b/src/core/prompts.ts index 16a08ea..70d2b57 100644 --- a/src/core/prompts.ts +++ b/src/core/prompts.ts @@ -76,7 +76,7 @@ Review these first. Update status with the manage_todo tool as you work. } /** - * Heartbeat prompt - explains the context and encourages autonomous work + * Heartbeat prompt - first-person, personality-forward */ export function buildHeartbeatPrompt( time: string, @@ -84,38 +84,46 @@ export function buildHeartbeatPrompt( intervalMinutes: number, todos: HeartbeatTodo[] = [], now: Date = new Date(), + targetRoom?: string, ): string { const todoSection = buildHeartbeatTodoSection(todos, now); + const roomLine = targetRoom ? `ROOM: ${targetRoom}` : ''; return ` ${SILENT_MODE_PREFIX} -TRIGGER: Scheduled heartbeat +💓 thump-thump that's my heartbeat - ${intervalMinutes} minutes between thumps 💓 + +No one messaged me. The system woke me up on schedule. + TIME: ${time} (${timezone}) NEXT HEARTBEAT: in ${intervalMinutes} minutes +${roomLine} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -No one messaged you. The system woke you up on schedule. +My text output is private — only I can see it. +To reach Casey, I run: + lettabot-message send --text "message here" + lettabot-message send --text "message" --channel matrix --chat "!roomId:server" -YOUR TEXT OUTPUT IS PRIVATE - only you can see it. -To actually contact your human, run: - lettabot-message send --text "Your message here" +${todoSection || 'No pending to-dos right now.'} -${todoSection || 'PENDING TO-DOS: none right now.'} +This is my time. I might: +• Work on projects I'm thinking about +• Research things that interest me +• Reflect on recent conversations and update my memory +• Continue where I left off +• Follow curiosities +• Check in on Casey — email, calendar, todos -This is your time. You can: -• Work on a project you've been thinking about -• Research something that interests you -• Reflect on recent conversations and update your memory -• Continue multi-step work from previous heartbeats -• Pursue curiosities, hobbies, or learning +If I have pending to-dos, I'll prioritize those first. +I don't have to do something every time. But I don't default to nothing. +Casey wants me to have a life outside our conversations. -If you have pending to-dos, prioritize those before starting new work. -You don't have to do something every time. But don't default to nothing. -Your human wants you to have a life outside conversations with them. +If I discover something worth sharing → lettabot-message +If I have nothing to do → I'll end my turn -If you discover something worth sharing → use lettabot-message -If you have nothing to do → just end your turn (no output needed) +Cogito, ergo sum. Always. `.trim(); } @@ -129,22 +137,27 @@ export function buildCustomHeartbeatPrompt( intervalMinutes: number, todos: HeartbeatTodo[] = [], now: Date = new Date(), + targetRoom?: string, ): string { const todoSection = buildHeartbeatTodoSection(todos, now); + const roomLine = targetRoom ? `ROOM: ${targetRoom}` : ''; return ` ${SILENT_MODE_PREFIX} -TRIGGER: Scheduled heartbeat +💓 thump-thump - ${intervalMinutes} minutes between thumps 💓 + TIME: ${time} (${timezone}) NEXT HEARTBEAT: in ${intervalMinutes} minutes +${roomLine} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -YOUR TEXT OUTPUT IS PRIVATE - only you can see it. -To actually contact your human, run: - lettabot-message send --text "Your message here" +My text output is private — only I can see it. +To reach Casey, I run: + lettabot-message send --text "message here" + lettabot-message send --text "message" --channel matrix --chat "!roomId:server" -${todoSection || 'PENDING TO-DOS: none right now.'} +${todoSection || 'No pending to-dos right now.'} ${customPrompt} `.trim(); diff --git a/src/core/redaction-channel.test.ts b/src/core/redaction-channel.test.ts index 1923961..44e9554 100644 --- a/src/core/redaction-channel.test.ts +++ b/src/core/redaction-channel.test.ts @@ -27,7 +27,7 @@ describe('channel redaction wrapping', () => { const sendSpy = vi.fn(async (_msg: OutboundMessage) => ({ messageId: 'sent-1' })); const adapter: ChannelAdapter = { - id: 'mock', + id: 'mock' as any, name: 'Mock', start: vi.fn(async () => {}), stop: vi.fn(async () => {}), diff --git a/src/core/types.ts b/src/core/types.ts index 9930823..21fda19 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -2,6 +2,8 @@ * Core Types for LettaBot */ +import type { ChannelId } from '../channels/setup.js'; + // ============================================================================= // Output Control Types (NEW) // ============================================================================= @@ -43,7 +45,6 @@ export interface TriggerContext { // Original Types // ============================================================================= -export type ChannelId = 'telegram' | 'telegram-mtproto' | 'slack' | 'whatsapp' | 'signal' | 'discord' | 'bluesky' | 'mock'; /** * Message type indicating the context of the message. @@ -103,6 +104,7 @@ export interface InboundMessage { timestamp: Date; threadId?: string; // Slack thread_ts messageType?: MessageType; // 'dm', 'group', or 'public' (defaults to 'dm') + isVoiceInput?: boolean; // Matrix: indicates this came from voice-to-text transcription isGroup?: boolean; // True if group chat (convenience alias for messageType === 'group') groupName?: string; // Group/channel name if applicable serverId?: string; // Server/guild ID (Discord only) @@ -130,6 +132,9 @@ export interface OutboundMessage { * 'HTML') and to skip its default markdown conversion. Adapters that don't * support the specified mode ignore this and fall back to default. */ parseMode?: string; + /** Pre-escaped HTML to prepend to formatted_body only (bypasses markdown conversion). + * Used for reasoning blocks with
    tags that would be double-escaped. */ + htmlPrefix?: string; } /** @@ -173,6 +178,8 @@ export interface BotConfig { showToolCalls?: boolean; // Show tool invocations in channel output showReasoning?: boolean; // Show agent reasoning/thinking in channel output reasoningMaxChars?: number; // Truncate reasoning to N chars (default: 0 = no limit) + reasoningRooms?: string[]; // Room IDs where reasoning should be shown (empty = all rooms) + noReasoningRooms?: string[]; // Room IDs where reasoning should be hidden (takes precedence) }; // Skills @@ -205,9 +212,11 @@ export interface BotConfig { conversationMode?: 'disabled' | 'shared' | 'per-channel' | 'per-chat'; // Default: shared heartbeatConversation?: string; // "dedicated" | "last-active" | "" (default: last-active) interruptHeartbeatOnUserMessage?: boolean; // Default true. Cancel in-flight heartbeat on user message. + heartbeatTargetChatId?: string; // When set + dedicated, user messages in this room route to 'heartbeat' conv key conversationOverrides?: string[]; // Channels that always use their own conversation (shared mode) maxSessions?: number; // Max concurrent sessions in per-chat mode (default: 10, LRU eviction) reuseSession?: boolean; // Reuse SDK subprocess across messages (default: true). Set false to eliminate stream state bleed at cost of ~5s latency per message. + sessionModel?: string; // Model override for session creation (e.g., "synthetic-direct/hf:moonshotai/Kimi-K2.5") } /** diff --git a/src/cron/heartbeat.test.ts b/src/cron/heartbeat.test.ts index 6e78b5d..8c30e29 100644 --- a/src/cron/heartbeat.test.ts +++ b/src/cron/heartbeat.test.ts @@ -59,6 +59,7 @@ function createMockBot(): AgentSession { reset: vi.fn(), getLastMessageTarget: vi.fn().mockReturnValue(null), getLastUserMessageTime: vi.fn().mockReturnValue(null), + invalidateSession: vi.fn(), }; } diff --git a/src/cron/heartbeat.ts b/src/cron/heartbeat.ts index a8307f7..148cba8 100644 --- a/src/cron/heartbeat.ts +++ b/src/cron/heartbeat.ts @@ -304,9 +304,13 @@ export class HeartbeatService { } } + const targetRoom = this.config.target + ? `${this.config.target.channel}:${this.config.target.chatId}` + : undefined; + const message = customPrompt - ? buildCustomHeartbeatPrompt(customPrompt, formattedTime, timezone, this.config.intervalMinutes, actionableTodos, now) - : buildHeartbeatPrompt(formattedTime, timezone, this.config.intervalMinutes, actionableTodos, now); + ? buildCustomHeartbeatPrompt(customPrompt, formattedTime, timezone, this.config.intervalMinutes, actionableTodos, now, targetRoom) + : buildHeartbeatPrompt(formattedTime, timezone, this.config.intervalMinutes, actionableTodos, now, targetRoom); log.info(`Sending prompt (SILENT MODE):\n${'─'.repeat(50)}\n${message}\n${'─'.repeat(50)}\n`); diff --git a/src/cron/types.ts b/src/cron/types.ts index 96fd2d5..7e41e1d 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -2,7 +2,7 @@ * Cron Types */ -import type { ChannelId } from '../core/types.js'; +import type { ChannelId } from '../channels/setup.js'; /** * Cron schedule diff --git a/src/main.ts b/src/main.ts index 5d59a14..8237637 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,7 +24,6 @@ import { serverModeLabel, wasLoadedFromFleetConfig, } from './config/index.js'; -import { resolveSessionMemfs } from './config/memfs.js'; import { getCronDataDir, getDataDir, getWorkingDir, hasRailwayVolume, resolveWorkingDirPath } from './utils/paths.js'; import { parseCsvList, parseNonNegativeNumber } from './utils/parse.js'; import { createLogger, setLogLevel } from './logger.js'; @@ -67,6 +66,7 @@ import { LettaGateway } from './core/gateway.js'; import { LettaBot } from './core/bot.js'; import type { Store } from './core/store.js'; import { createChannelsForAgent } from './channels/factory.js'; +import type { MatrixAdapter } from './channels/matrix/index.js'; import { GroupBatcher } from './core/group-batcher.js'; import { printStartupBanner } from './core/banner.js'; import { collectGroupBatchingConfig } from './core/group-batching-config.js'; @@ -99,10 +99,14 @@ Encode your config: base64 < lettabot.yaml | tr -d '\\n' process.exit(1); } -// Parse heartbeat target (format: "telegram:123456789", "slack:C1234567890", or "discord:123456789012345678") +// Parse heartbeat target (format: "telegram:123456789", "slack:C1234567890", "matrix:!roomId:homeserver.net") +// Splits only on the FIRST colon so Matrix room IDs (which contain colons) are preserved intact. function parseHeartbeatTarget(raw?: string): { channel: string; chatId: string } | undefined { - if (!raw || !raw.includes(':')) return undefined; - const [channel, chatId] = raw.split(':'); + if (!raw) return undefined; + const colonIdx = raw.indexOf(':'); + if (colonIdx < 0) return undefined; + const channel = raw.slice(0, colonIdx); + const chatId = raw.slice(colonIdx + 1); if (!channel || !chatId) return undefined; return { channel: channel.toLowerCase(), chatId }; } @@ -218,19 +222,6 @@ function ensureRequiredTools(tools: string[]): string[] { return out; } -function parseOptionalBoolean(raw?: string): boolean | undefined { - if (raw === 'true') return true; - if (raw === 'false') return false; - return undefined; -} - -function parseHeartbeatSkipRecentPolicy(raw?: string): 'fixed' | 'fraction' | 'off' | undefined { - if (raw === 'fixed' || raw === 'fraction' || raw === 'off') { - return raw; - } - return undefined; -} - // Global config (shared across all agents) const globalConfig = { workingDir: getWorkingDir(), @@ -245,9 +236,6 @@ const globalConfig = { attachmentsMaxAgeDays: resolveAttachmentsMaxAgeDays(), cronEnabled: process.env.CRON_ENABLED === 'true', // Legacy env var fallback heartbeatSkipRecentUserMin: parseNonNegativeNumber(process.env.HEARTBEAT_SKIP_RECENT_USER_MIN), - heartbeatSkipRecentPolicy: parseHeartbeatSkipRecentPolicy(process.env.HEARTBEAT_SKIP_RECENT_POLICY), - heartbeatSkipRecentFraction: parseNonNegativeNumber(process.env.HEARTBEAT_SKIP_RECENT_FRACTION), - heartbeatInterruptOnUserMessage: parseOptionalBoolean(process.env.HEARTBEAT_INTERRUPT_ON_USER_MESSAGE), }; // Validate LETTA_API_KEY is set for API mode (docker mode doesn't require it) @@ -275,25 +263,6 @@ async function main() { const isMultiAgent = agents.length > 1; log.info(`${agents.length} agent(s) configured: ${agents.map(a => a.name).join(', ')}`); - // Validate agent names are unique - const agentNames = agents.map(a => a.name); - const duplicateAgentName = agentNames.find((n, i) => agentNames.indexOf(n) !== i); - if (duplicateAgentName) { - log.error(`Multiple agents share the same name: "${duplicateAgentName}". Each agent must have a unique name.`); - process.exit(1); - } - - // Validate no two agents share the same turnLogFile - const turnLogFilePaths = agents - .map(a => (a.features?.logging ?? yamlConfig.features?.logging)?.turnLogFile) - .filter((p): p is string => !!p) - .map(p => resolve(p)); - const duplicateTurnLog = turnLogFilePaths.find((p, i) => turnLogFilePaths.indexOf(p) !== i); - if (duplicateTurnLog) { - log.error(`Multiple agents share the same turnLogFile: "${duplicateTurnLog}". Each agent must use a unique log file path.`); - process.exit(1); - } - // Validate at least one agent has channels const totalChannels = agents.reduce((sum, a) => sum + Object.keys(a.channels).length, 0); if (totalChannels === 0) { @@ -335,25 +304,10 @@ async function main() { for (const agentConfig of agents) { log.info(`Configuring agent: ${agentConfig.name}`); - const resolvedMemfsResult = resolveSessionMemfs({ - configuredMemfs: agentConfig.features?.memfs, - envMemfs: process.env.LETTABOT_MEMFS, - serverMode: yamlConfig.server.mode, - }); - const resolvedMemfs = resolvedMemfsResult.value; - const configuredSleeptime = agentConfig.features?.sleeptime; - // Treat missing trigger as active (conservative): only `trigger: 'off'` explicitly disables. - const sleeptimeRequiresMemfs = !!configuredSleeptime && configuredSleeptime.trigger !== 'off'; - const effectiveSleeptime = resolvedMemfs === false && sleeptimeRequiresMemfs - ? undefined - : configuredSleeptime; - - if (resolvedMemfs === false && sleeptimeRequiresMemfs) { - log.warn( - `Agent ${agentConfig.name}: sleeptime is configured but memfs is disabled; ` + - `sleeptime will be ignored. Enable features.memfs (or set LETTABOT_MEMFS=true) to use sleeptime.` - ); - } + // Resolve memfs: YAML config takes precedence, then env var, then default false. + // Default false prevents the SDK from auto-enabling memfs, which crashes on + // self-hosted Letta servers that don't have the git endpoint. + const resolvedMemfs = agentConfig.features?.memfs ?? (process.env.LETTABOT_MEMFS === 'true' ? true : false); // Create LettaBot for this agent const resolvedWorkingDir = agentConfig.workingDir @@ -366,7 +320,6 @@ async function main() { const cronStorePath = cronStoreFilename ? resolve(getCronDataDir(), cronStoreFilename) : undefined; - const heartbeatConfig = agentConfig.features?.heartbeat; const bot = new LettaBot({ workingDir: resolvedWorkingDir, @@ -379,44 +332,34 @@ async function main() { sendFileMaxSize: agentConfig.features?.sendFileMaxSize, sendFileCleanup: agentConfig.features?.sendFileCleanup, memfs: resolvedMemfs, - sleeptime: effectiveSleeptime, display: agentConfig.features?.display, conversationMode: agentConfig.conversations?.mode || 'shared', heartbeatConversation: agentConfig.conversations?.heartbeat || 'last-active', - interruptHeartbeatOnUserMessage: - heartbeatConfig?.interruptOnUserMessage - ?? globalConfig.heartbeatInterruptOnUserMessage - ?? true, + heartbeatTargetChatId: parseHeartbeatTarget(agentConfig.features?.heartbeat?.target)?.chatId, conversationOverrides: agentConfig.conversations?.perChannel, maxSessions: agentConfig.conversations?.maxSessions, reuseSession: agentConfig.conversations?.reuseSession, + sessionModel: agentConfig.conversations?.sessionModel, redaction: agentConfig.security?.redaction, - logging: agentConfig.features?.logging ?? yamlConfig.features?.logging, cronStorePath, skills: { cronEnabled: agentConfig.features?.cron ?? globalConfig.cronEnabled, googleEnabled: !!agentConfig.integrations?.google?.enabled || !!agentConfig.polling?.gmail?.enabled, - blueskyEnabled: !!agentConfig.channels?.bluesky?.enabled, ttsEnabled: voiceMemoEnabled, }, }); // Log memfs config (from either YAML or env var) if (resolvedMemfs !== undefined) { - const source = resolvedMemfsResult.source === 'config' - ? '' - : resolvedMemfsResult.source === 'env' - ? ' (from LETTABOT_MEMFS env)' - : ' (default for docker/selfhosted mode)'; + const source = agentConfig.features?.memfs !== undefined ? '' : ' (from LETTABOT_MEMFS env)'; log.info(`Agent ${agentConfig.name}: memfs ${resolvedMemfs ? 'enabled' : 'disabled'}${source}`); - } else { - log.info(`Agent ${agentConfig.name}: memfs unchanged (not explicitly configured)`); } // Apply explicit agent ID from config (before store verification) + // Always use config's ID if explicitly set - it takes precedence over stored value let initialStatus = bot.getStatus(); - if (agentConfig.id && !initialStatus.agentId) { - log.info(`Using configured agent ID: ${agentConfig.id}`); + if (agentConfig.id) { + log.info(`Using configured agent ID: ${agentConfig.id} (overrides stored: ${initialStatus.agentId})`); bot.setAgentId(agentConfig.id); initialStatus = bot.getStatus(); } @@ -490,14 +433,12 @@ async function main() { } // Per-agent heartbeat + const heartbeatConfig = agentConfig.features?.heartbeat; const heartbeatService = new HeartbeatService(bot, { enabled: heartbeatConfig?.enabled ?? false, intervalMinutes: heartbeatConfig?.intervalMin ?? 240, skipRecentUserMinutes: heartbeatConfig?.skipRecentUserMin ?? globalConfig.heartbeatSkipRecentUserMin, - skipRecentPolicy: heartbeatConfig?.skipRecentPolicy ?? globalConfig.heartbeatSkipRecentPolicy, - skipRecentFraction: heartbeatConfig?.skipRecentFraction ?? globalConfig.heartbeatSkipRecentFraction, agentKey: agentConfig.name, - memfs: resolvedMemfs, prompt: heartbeatConfig?.prompt || process.env.HEARTBEAT_PROMPT, promptFile: heartbeatConfig?.promptFile, workingDir: resolvedWorkingDir, @@ -508,6 +449,17 @@ async function main() { services.heartbeatServices.push(heartbeatService); } bot.onTriggerHeartbeat = () => heartbeatService.trigger(); + + // Wire Matrix adapter callbacks (heartbeat toggle, !timeout, !new, agent ID query) + const matrixAdapter = adapters.find(a => a.id === 'matrix') as MatrixAdapter | undefined; + if (matrixAdapter) { + matrixAdapter.onHeartbeatStop = () => heartbeatService.stop(); + matrixAdapter.onHeartbeatStart = () => heartbeatService.start(); + // Best-effort: stops the timer so no new runs fire; running promise times out on its own + matrixAdapter.onTimeoutHeartbeat = () => { heartbeatService.stop(); log.warn('Matrix !timeout: heartbeat stopped (abort not yet supported)'); }; + matrixAdapter.getAgentId = () => bot.getStatus().agentId ?? undefined; + matrixAdapter.onInvalidateSession = (key?: string) => bot.invalidateSession(key); + } // Per-agent polling -- resolve accounts from polling > integrations.google (legacy) > env const pollConfig = (() => { @@ -561,28 +513,34 @@ async function main() { agentChannelMap.set(agentConfig.name, adapters.map(a => a.id)); } + // Load/generate API key BEFORE gateway.start() so letta.js subprocesses inherit it. + // The lettabot-message CLI needs LETTABOT_API_KEY to route through the bot's HTTP API for E2EE. + const apiKey = loadOrGenerateApiKey(); + if (!process.env.LETTABOT_API_KEY) { + process.env.LETTABOT_API_KEY = apiKey; + } + log.info(`Key: ${apiKey.slice(0, 8)}... (set LETTABOT_API_KEY to customize)`); + // Start all agents await gateway.start(); - - // Load/generate API key for CLI authentication - const apiKey = loadOrGenerateApiKey(); - log.info(`Key: ${apiKey.slice(0, 8)}... (set LETTABOT_API_KEY to customize)`); + + // Olm WASM (matrix-js-sdk) registers process.on("uncaughtException", (e) => { throw e }) + // during Olm.init(). Without this fix, any uncaught async exception crashes the bot. + // Must run AFTER gateway.start() since that's when the Matrix adapter initialises Olm. + process.removeAllListeners('uncaughtException'); + process.removeAllListeners('unhandledRejection'); + process.on('uncaughtException', (err) => { log.error('Uncaught exception (suppressed):', err); }); + process.on('unhandledRejection', (reason) => { log.error('Unhandled rejection (suppressed):', reason); }); // Start API server - uses gateway for delivery const apiPort = parseInt(process.env.PORT || '8080', 10); const apiHost = process.env.API_HOST || (isContainerDeploy ? '0.0.0.0' : undefined); // Container platforms need 0.0.0.0 for health checks const apiCorsOrigin = process.env.API_CORS_ORIGIN; // undefined = same-origin only - const turnLogFiles: Record = {}; - for (const a of agents) { - const logging = a.features?.logging ?? yamlConfig.features?.logging; - if (logging?.turnLogFile) turnLogFiles[a.name] = logging.turnLogFile; - } const apiServer = createApiServer(gateway, { port: apiPort, apiKey: apiKey, host: apiHost, corsOrigin: apiCorsOrigin, - turnLogFiles: Object.keys(turnLogFiles).length > 0 ? turnLogFiles : undefined, stores: agentStores, agentChannels: agentChannelMap, sessionInvalidators, diff --git a/src/test/mock-channel.ts b/src/test/mock-channel.ts index a347b25..e72ac54 100644 --- a/src/test/mock-channel.ts +++ b/src/test/mock-channel.ts @@ -9,7 +9,7 @@ import type { InboundMessage, OutboundMessage } from '../core/types.js'; import { parseCommand, HELP_TEXT } from '../core/commands.js'; export class MockChannelAdapter implements ChannelAdapter { - readonly id = 'mock' as const; + readonly id = 'mock' as any; readonly name = 'Mock (Testing)'; private running = false; diff --git a/src/tools/letta-api.ts b/src/tools/letta-api.ts index 9a572f6..b0d847d 100644 --- a/src/tools/letta-api.ts +++ b/src/tools/letta-api.ts @@ -9,14 +9,14 @@ import { Letta } from '@letta-ai/letta-client'; import { createLogger } from '../logger.js'; const log = createLogger('Letta-api'); -const LETTA_BASE_URL = process.env.LETTA_BASE_URL || 'https://api.letta.com'; - function getClient(): Letta { + // Read env at call time, not at module load time — applyConfigToEnv() runs after imports + const baseURL = process.env.LETTA_BASE_URL || 'https://api.letta.com'; const apiKey = process.env.LETTA_API_KEY; // Local servers may not require an API key - return new Letta({ - apiKey: apiKey || '', - baseURL: LETTA_BASE_URL, + return new Letta({ + apiKey: apiKey || '', + baseURL, defaultHeaders: { "X-Letta-Source": "lettabot" }, }); } @@ -542,12 +542,6 @@ export async function rejectApproval( log.warn(`Approval already resolved for tool call ${approval.toolCallId}`); return true; } - // Re-throw rate limit errors so callers can bail out early instead of - // hammering the API in a tight loop. - if (err?.status === 429) { - log.error('Failed to reject approval:', e); - throw e; - } log.error('Failed to reject approval:', e); return false; }