From 8e24a31bd46037071e15854e61b9044e12198af3 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 14 Mar 2026 12:04:06 +0000 Subject: [PATCH] feat: add Telegram Topics (forum mode) support - buildJid() constructs tg:{chatId}:{threadId} for topic messages - parseJid() extracts chatId + threadId from JID for outbound routing - /chatid command shows thread ID in forum topics - sendMessage and setTyping pass message_thread_id when present - All message handlers (text, photo, voice, media) use thread-aware JIDs Allows each forum topic to be registered as an independent Nanoclaw group. --- package-lock.json | 1536 ++++++++++++++++++++++++++++++++- package.json | 10 +- src/channels/telegram.test.ts | 1024 ++++++++++++++++++++++ src/channels/telegram.ts | 366 ++++++++ src/channels/whatsapp.test.ts | 1032 ++++++++++++++++++++++ src/channels/whatsapp.ts | 400 +++++++++ src/config.ts | 31 +- src/container-runner.test.ts | 1 - src/container-runner.ts | 112 ++- src/container-runtime.ts | 58 +- src/db.test.ts | 113 +-- src/db.ts | 77 +- src/formatting.test.ts | 51 +- src/group-queue.test.ts | 35 - src/group-queue.ts | 25 +- src/index.ts | 204 ++--- src/ipc-auth.test.ts | 87 +- src/ipc.ts | 133 +-- src/router.ts | 19 +- src/routing.test.ts | 109 +++ src/task-scheduler.test.ts | 76 -- src/task-scheduler.ts | 109 +-- src/transcription.ts | 112 +++ src/types.ts | 5 +- src/whatsapp-auth.ts | 180 ++++ vitest.config.ts | 2 +- 26 files changed, 5190 insertions(+), 717 deletions(-) create mode 100644 src/channels/telegram.test.ts create mode 100644 src/channels/telegram.ts create mode 100644 src/channels/whatsapp.test.ts create mode 100644 src/channels/whatsapp.ts create mode 100644 src/transcription.ts create mode 100644 src/whatsapp-auth.ts diff --git a/package-lock.json b/package-lock.json index 8d63787..1475ba6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,29 @@ { "name": "nanoclaw", - "version": "1.2.12", + "version": "1.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.12", + "version": "1.1.3", "dependencies": { + "@whiskeysockets/baileys": "^7.0.0-rc.9", "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", + "grammy": "^1.39.3", + "openai": "^6.25.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", + "qrcode": "^1.5.4", + "qrcode-terminal": "^0.12.0", "yaml": "^2.8.2", "zod": "^4.3.6" }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.10.0", + "@types/qrcode-terminal": "^0.12.2", "@vitest/coverage-v8": "^4.0.18", "husky": "^9.1.7", "prettier": "^3.8.1", @@ -89,6 +95,62 @@ "node": ">=18" } }, + "node_modules/@borewit/text-codec": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz", + "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.7.tgz", + "integrity": "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==", + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.3.3", + "@keyv/bigmap": "^1.3.0", + "hookified": "^1.14.0", + "keyv": "^5.5.5" + } + }, + "node_modules/@cacheable/node-cache": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz", + "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==", + "license": "MIT", + "dependencies": { + "cacheable": "^2.3.1", + "hookified": "^1.14.0", + "keyv": "^5.5.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.4.tgz", + "integrity": "sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==", + "license": "MIT", + "dependencies": { + "hashery": "^1.3.0", + "keyv": "^5.6.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -531,6 +593,492 @@ "node": ">=18" } }, + "node_modules/@grammyjs/types": { + "version": "3.24.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.24.0.tgz", + "integrity": "sha512-qQIEs4lN5WqUdr4aT8MeU6UFpMbGYAvcvYSW1A4OO1PABGJQHz/KLON6qvpf+5RxaNDQBxiY2k2otIhg/AG7RQ==", + "license": "MIT" + }, + "node_modules/@hapi/boom": { + "version": "9.1.4", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz", + "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "9.x.x" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "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==", + "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==", + "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/@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/@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/@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/@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/@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/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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/@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/@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/@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/@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/@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/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "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-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "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-riscv64": "1.2.4" + } + }, + "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/@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/@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/@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/@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/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "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/@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/@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/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -559,12 +1107,98 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT" + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -922,6 +1556,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -957,16 +1614,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode-terminal": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", + "integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/coverage-v8": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", @@ -1109,6 +1778,81 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@whiskeysockets/baileys": { + "version": "7.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc.9.tgz", + "integrity": "sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@cacheable/node-cache": "^1.4.0", + "@hapi/boom": "^9.1.3", + "async-mutex": "^0.5.0", + "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git", + "lru-cache": "^11.1.0", + "music-metadata": "^11.7.0", + "p-queue": "^9.0.0", + "pino": "^9.6", + "protobufjs": "^7.2.4", + "ws": "^8.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "audio-decode": "^2.1.3", + "jimp": "^1.6.0", + "link-preview-js": "^3.0.0", + "sharp": "*" + }, + "peerDependenciesMeta": { + "audio-decode": { + "optional": true + }, + "jimp": { + "optional": true + }, + "link-preview-js": { + "optional": true + } + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "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", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1131,6 +1875,15 @@ "js-tokens": "^10.0.0" } }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -1215,6 +1968,28 @@ "ieee754": "^1.1.13" } }, + "node_modules/cacheable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.2.tgz", + "integrity": "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA==", + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.0.7", + "@cacheable/utils": "^2.3.3", + "hookified": "^1.15.0", + "keyv": "^5.5.5", + "qified": "^0.6.0" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1231,12 +2006,50 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "license": "MIT" }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cron-parser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", @@ -1249,6 +2062,12 @@ "node": ">=18" } }, + "node_modules/curve25519-js": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz", + "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==", + "license": "MIT" + }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -1258,6 +2077,32 @@ "node": "*" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1291,6 +2136,18 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "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" + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1359,6 +2216,21 @@ "@types/estree": "^1.0.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1408,12 +2280,43 @@ } } }, + "node_modules/file-type": { + "version": "21.3.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz", + "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "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" }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -1435,6 +2338,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", @@ -1454,6 +2366,21 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/grammy": { + "version": "1.40.1", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.40.1.tgz", + "integrity": "sha512-bTe8SWXD8/Sdt2LGAAAsFGhuxI9RG8zL2gGk3V42A/RxriPqBQqwMGoNSldNK1qIFD2EaVuq7NQM8+ZAmNgHLw==", + "license": "MIT", + "dependencies": { + "@grammyjs/types": "3.24.0", + "abort-controller": "^3.0.0", + "debug": "^4.4.3", + "node-fetch": "^2.7.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1464,12 +2391,30 @@ "node": ">=8" } }, + "node_modules/hashery": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.4.0.tgz", + "integrity": "sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==", + "license": "MIT", + "dependencies": { + "hookified": "^1.14.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/help-me": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", "license": "MIT" }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -1525,6 +2470,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -1580,6 +2534,91 @@ "dev": true, "license": "MIT" }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/libsignal": { + "name": "@whiskeysockets/libsignal-node", + "version": "2.0.1", + "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67", + "license": "GPL-3.0", + "dependencies": { + "curve25519-js": "^0.0.4", + "protobufjs": "6.8.8" + } + }, + "node_modules/libsignal/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "license": "MIT" + }, + "node_modules/libsignal/node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/libsignal/node_modules/protobufjs": { + "version": "6.8.8", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz", + "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.0", + "@types/node": "^10.1.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "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==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -1627,6 +2666,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -1654,6 +2702,43 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/music-metadata": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.0.tgz", + "integrity": "sha512-9ChYnmVmyHvFxR2g0MWFSHmJfbssRy07457G4gbb4LA9WYvyZea/8EMbqvg5dcv4oXNCNL01m8HXtymLlhhkYg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "file-type": "^21.3.0", + "media-typer": "^1.1.0", + "strtok3": "^10.3.4", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0", + "win-guid": "^0.2.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1691,6 +2776,26 @@ "node": ">=10" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1720,6 +2825,100 @@ "wrappy": "1" } }, + "node_modules/openai": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.25.0.tgz", + "integrity": "sha512-mEh6VZ2ds2AGGokWARo18aPISI1OhlgdEIC1ewhkZr8pSIT31dec0ecr9Nhxx0JlybyOgoAT1sWeKtwPZzJyww==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-queue": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz", + "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1740,6 +2939,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1817,6 +3017,15 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -1904,6 +3113,30 @@ ], "license": "MIT" }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -1914,6 +3147,43 @@ "once": "^1.3.1" } }, + "node_modules/qified": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz", + "integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==", + "license": "MIT", + "dependencies": { + "hookified": "^1.14.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -1967,6 +3237,21 @@ "node": ">= 12.13.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2079,6 +3364,57 @@ "node": ">=10" } }, + "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" + }, + "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", + "peer": true, + "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/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2182,6 +3518,32 @@ "safe-buffer": "~5.2.0" } }, + "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", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", @@ -2194,6 +3556,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strtok3": { + "version": "10.3.4", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", + "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -2288,12 +3666,43 @@ "node": ">=14.0.0" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -2334,11 +3743,22 @@ "node": ">=14.17" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/util-deprecate": { @@ -2353,6 +3773,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2428,6 +3849,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -2500,6 +3922,28 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2517,17 +3961,65 @@ "node": ">=8" } }, + "node_modules/win-guid": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz", + "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -2538,11 +4030,47 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 5fae6f4..682db49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.12", + "version": "1.1.3", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", @@ -8,27 +8,33 @@ "build": "tsc", "start": "node dist/index.js", "dev": "tsx src/index.ts", + "auth": "tsx src/whatsapp-auth.ts", "typecheck": "tsc --noEmit", "format": "prettier --write \"src/**/*.ts\"", "format:fix": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", "prepare": "husky", "setup": "tsx setup/index.ts", - "auth": "tsx src/whatsapp-auth.ts", "test": "vitest run", "test:watch": "vitest" }, "dependencies": { + "@whiskeysockets/baileys": "^7.0.0-rc.9", "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", + "grammy": "^1.39.3", + "openai": "^6.25.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", + "qrcode": "^1.5.4", + "qrcode-terminal": "^0.12.0", "yaml": "^2.8.2", "zod": "^4.3.6" }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.10.0", + "@types/qrcode-terminal": "^0.12.2", "@vitest/coverage-v8": "^4.0.18", "husky": "^9.1.7", "prettier": "^3.8.1", diff --git a/src/channels/telegram.test.ts b/src/channels/telegram.test.ts new file mode 100644 index 0000000..8ec76a5 --- /dev/null +++ b/src/channels/telegram.test.ts @@ -0,0 +1,1024 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +// --- Mocks --- + +// Mock config +vi.mock('../config.js', () => ({ + ASSISTANT_NAME: 'Andy', + TRIGGER_PATTERN: /^@Andy\b/i, +})); + +// Mock logger +vi.mock('../logger.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock transcription +const mockTranscribeBuffer = vi.fn(); +vi.mock('../transcription.js', () => ({ + transcribeBuffer: (...args: any[]) => mockTranscribeBuffer(...args), +})); + +// --- Grammy mock --- + +type Handler = (...args: any[]) => any; + +const botRef = vi.hoisted(() => ({ current: null as any })); + +vi.mock('grammy', () => ({ + Bot: class MockBot { + token: string; + commandHandlers = new Map(); + filterHandlers = new Map(); + errorHandler: Handler | null = null; + + api = { + sendMessage: vi.fn().mockResolvedValue(undefined), + sendChatAction: vi.fn().mockResolvedValue(undefined), + }; + + constructor(token: string) { + this.token = token; + botRef.current = this; + } + + command(name: string, handler: Handler) { + this.commandHandlers.set(name, handler); + } + + on(filter: string, handler: Handler) { + const existing = this.filterHandlers.get(filter) || []; + existing.push(handler); + this.filterHandlers.set(filter, existing); + } + + catch(handler: Handler) { + this.errorHandler = handler; + } + + start(opts: { onStart: (botInfo: any) => void }) { + opts.onStart({ username: 'andy_ai_bot', id: 12345 }); + } + + stop() {} + }, +})); + +import { TelegramChannel, TelegramChannelOpts } from './telegram.js'; + +// --- Test helpers --- + +function createTestOpts( + overrides?: Partial, +): TelegramChannelOpts { + return { + onMessage: vi.fn(), + onChatMetadata: vi.fn(), + clearSession: vi.fn(), + registeredGroups: vi.fn(() => ({ + 'tg:100200300': { + name: 'Test Group', + folder: 'test-group', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + })), + ...overrides, + }; +} + +function createTextCtx(overrides: { + chatId?: number; + chatType?: string; + chatTitle?: string; + text: string; + fromId?: number; + firstName?: string; + username?: string; + messageId?: number; + date?: number; + entities?: any[]; +}) { + const chatId = overrides.chatId ?? 100200300; + const chatType = overrides.chatType ?? 'group'; + return { + chat: { + id: chatId, + type: chatType, + title: overrides.chatTitle ?? 'Test Group', + }, + from: { + id: overrides.fromId ?? 99001, + first_name: overrides.firstName ?? 'Alice', + username: overrides.username ?? 'alice_user', + }, + message: { + text: overrides.text, + date: overrides.date ?? Math.floor(Date.now() / 1000), + message_id: overrides.messageId ?? 1, + entities: overrides.entities ?? [], + }, + me: { username: 'andy_ai_bot' }, + reply: vi.fn(), + }; +} + +function createMediaCtx(overrides: { + chatId?: number; + chatType?: string; + fromId?: number; + firstName?: string; + date?: number; + messageId?: number; + caption?: string; + extra?: Record; +}) { + const chatId = overrides.chatId ?? 100200300; + return { + chat: { + id: chatId, + type: overrides.chatType ?? 'group', + title: 'Test Group', + }, + from: { + id: overrides.fromId ?? 99001, + first_name: overrides.firstName ?? 'Alice', + username: 'alice_user', + }, + message: { + date: overrides.date ?? Math.floor(Date.now() / 1000), + message_id: overrides.messageId ?? 1, + caption: overrides.caption, + ...(overrides.extra || {}), + }, + me: { username: 'andy_ai_bot' }, + }; +} + +function currentBot() { + return botRef.current; +} + +async function triggerTextMessage(ctx: ReturnType) { + const handlers = currentBot().filterHandlers.get('message:text') || []; + for (const h of handlers) await h(ctx); +} + +async function triggerMediaMessage( + filter: string, + ctx: ReturnType, +) { + const handlers = currentBot().filterHandlers.get(filter) || []; + for (const h of handlers) await h(ctx); +} + +// --- Tests --- + +describe('TelegramChannel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // --- Connection lifecycle --- + + describe('connection lifecycle', () => { + it('resolves connect() when bot starts', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + await channel.connect(); + + expect(channel.isConnected()).toBe(true); + }); + + it('registers command and message handlers on connect', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + await channel.connect(); + + expect(currentBot().commandHandlers.has('chatid')).toBe(true); + expect(currentBot().commandHandlers.has('ping')).toBe(true); + expect(currentBot().filterHandlers.has('message:text')).toBe(true); + expect(currentBot().filterHandlers.has('message:photo')).toBe(true); + expect(currentBot().filterHandlers.has('message:video')).toBe(true); + expect(currentBot().filterHandlers.has('message:voice')).toBe(true); + expect(currentBot().filterHandlers.has('message:audio')).toBe(true); + expect(currentBot().filterHandlers.has('message:document')).toBe(true); + expect(currentBot().filterHandlers.has('message:sticker')).toBe(true); + expect(currentBot().filterHandlers.has('message:location')).toBe(true); + expect(currentBot().filterHandlers.has('message:contact')).toBe(true); + }); + + it('registers error handler on connect', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + await channel.connect(); + + expect(currentBot().errorHandler).not.toBeNull(); + }); + + it('disconnects cleanly', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + await channel.connect(); + expect(channel.isConnected()).toBe(true); + + await channel.disconnect(); + expect(channel.isConnected()).toBe(false); + }); + + it('isConnected() returns false before connect', () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + expect(channel.isConnected()).toBe(false); + }); + }); + + // --- Text message handling --- + + describe('text message handling', () => { + it('delivers message for registered group', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: 'Hello everyone' }); + await triggerTextMessage(ctx); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'tg:100200300', + expect.any(String), + 'Test Group', + ); + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + id: '1', + chat_jid: 'tg:100200300', + sender: '99001', + sender_name: 'Alice', + content: 'Hello everyone', + is_from_me: false, + }), + ); + }); + + it('only emits metadata for unregistered chats', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ chatId: 999999, text: 'Unknown chat' }); + await triggerTextMessage(ctx); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'tg:999999', + expect.any(String), + 'Test Group', + ); + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + + it('skips command messages (starting with /)', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: '/start' }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).not.toHaveBeenCalled(); + expect(opts.onChatMetadata).not.toHaveBeenCalled(); + }); + + it('extracts sender name from first_name', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: 'Hi', firstName: 'Bob' }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ sender_name: 'Bob' }), + ); + }); + + it('falls back to username when first_name missing', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: 'Hi' }); + ctx.from.first_name = undefined as any; + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ sender_name: 'alice_user' }), + ); + }); + + it('falls back to user ID when name and username missing', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: 'Hi', fromId: 42 }); + ctx.from.first_name = undefined as any; + ctx.from.username = undefined as any; + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ sender_name: '42' }), + ); + }); + + it('uses sender name as chat name for private chats', async () => { + const opts = createTestOpts({ + registeredGroups: vi.fn(() => ({ + 'tg:100200300': { + name: 'Private', + folder: 'private', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + })), + }); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: 'Hello', + chatType: 'private', + firstName: 'Alice', + }); + await triggerTextMessage(ctx); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'tg:100200300', + expect.any(String), + 'Alice', // Private chats use sender name + ); + }); + + it('uses chat title as name for group chats', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: 'Hello', + chatType: 'supergroup', + chatTitle: 'Project Team', + }); + await triggerTextMessage(ctx); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'tg:100200300', + expect.any(String), + 'Project Team', + ); + }); + + it('converts message.date to ISO timestamp', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const unixTime = 1704067200; // 2024-01-01T00:00:00.000Z + const ctx = createTextCtx({ text: 'Hello', date: unixTime }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + timestamp: '2024-01-01T00:00:00.000Z', + }), + ); + }); + }); + + // --- @mention translation --- + + describe('@mention translation', () => { + it('translates @bot_username mention to trigger format', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: '@andy_ai_bot what time is it?', + entities: [{ type: 'mention', offset: 0, length: 12 }], + }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: '@Andy @andy_ai_bot what time is it?', + }), + ); + }); + + it('does not translate if message already matches trigger', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: '@Andy @andy_ai_bot hello', + entities: [{ type: 'mention', offset: 6, length: 12 }], + }); + await triggerTextMessage(ctx); + + // Should NOT double-prepend — already starts with @Andy + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: '@Andy @andy_ai_bot hello', + }), + ); + }); + + it('does not translate mentions of other bots', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: '@some_other_bot hi', + entities: [{ type: 'mention', offset: 0, length: 15 }], + }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: '@some_other_bot hi', // No translation + }), + ); + }); + + it('handles mention in middle of message', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: 'hey @andy_ai_bot check this', + entities: [{ type: 'mention', offset: 4, length: 12 }], + }); + await triggerTextMessage(ctx); + + // Bot is mentioned, message doesn't match trigger → prepend trigger + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: '@Andy hey @andy_ai_bot check this', + }), + ); + }); + + it('handles message with no entities', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: 'plain message' }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: 'plain message', + }), + ); + }); + + it('ignores non-mention entities', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: 'check https://example.com', + entities: [{ type: 'url', offset: 6, length: 19 }], + }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: 'check https://example.com', + }), + ); + }); + }); + + // --- Private DM trigger --- + + describe('private DM trigger', () => { + it('prepends trigger for private DMs without trigger', async () => { + const opts = createTestOpts({ + registeredGroups: vi.fn(() => ({ + 'tg:100200300': { + name: 'My DM', + folder: 'my-dm', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + })), + }); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: 'check the weather', + chatType: 'private', + }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '@Andy check the weather' }), + ); + }); + + it('does not double-prepend if DM already starts with trigger', async () => { + const opts = createTestOpts({ + registeredGroups: vi.fn(() => ({ + 'tg:100200300': { + name: 'My DM', + folder: 'my-dm', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + })), + }); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: '@Andy do something', + chatType: 'private', + }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '@Andy do something' }), + ); + }); + + it('does not prepend trigger for group chats without @mention', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + // group chat, no @mention entity — content should be unchanged + const ctx = createTextCtx({ text: 'hello everyone', chatType: 'group' }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: 'hello everyone' }), + ); + }); + }); + + // --- Non-text messages --- + + describe('non-text messages', () => { + it('stores photo with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:photo', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Photo]' }), + ); + }); + + it('stores photo with caption', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({ caption: 'Look at this' }); + await triggerMediaMessage('message:photo', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Photo] Look at this' }), + ); + }); + + it('stores video with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:video', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Video]' }), + ); + }); + + it('transcribes voice message', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + mockTranscribeBuffer.mockResolvedValue('Hello world'); + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(100)), + }) as any; + + const ctx = createMediaCtx({}); + (ctx as any).getFile = vi + .fn() + .mockResolvedValue({ file_path: 'voice/file.ogg' }); + await triggerMediaMessage('message:voice', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Voice: Hello world]' }), + ); + globalThis.fetch = originalFetch; + }); + + it('falls back when transcription fails', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + (ctx as any).getFile = vi.fn().mockRejectedValue(new Error('network')); + await triggerMediaMessage('message:voice', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: '[Voice Message - transcription failed]', + }), + ); + }); + + it('stores audio with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:audio', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Audio]' }), + ); + }); + + it('stores document with filename', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({ + extra: { document: { file_name: 'report.pdf' } }, + }); + await triggerMediaMessage('message:document', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Document: report.pdf]' }), + ); + }); + + it('stores document with fallback name when filename missing', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({ extra: { document: {} } }); + await triggerMediaMessage('message:document', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Document: file]' }), + ); + }); + + it('stores sticker with emoji', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({ + extra: { sticker: { emoji: '😂' } }, + }); + await triggerMediaMessage('message:sticker', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Sticker 😂]' }), + ); + }); + + it('stores location with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:location', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Location]' }), + ); + }); + + it('stores contact with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:contact', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Contact]' }), + ); + }); + + it('ignores non-text messages from unregistered chats', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({ chatId: 999999 }); + await triggerMediaMessage('message:photo', ctx); + + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + }); + + // --- sendMessage --- + + describe('sendMessage', () => { + it('sends message via bot API', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + await channel.sendMessage('tg:100200300', 'Hello'); + + expect(currentBot().api.sendMessage).toHaveBeenCalledWith( + '100200300', + 'Hello', + ); + }); + + it('strips tg: prefix from JID', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + await channel.sendMessage('tg:-1001234567890', 'Group message'); + + expect(currentBot().api.sendMessage).toHaveBeenCalledWith( + '-1001234567890', + 'Group message', + ); + }); + + it('splits messages exceeding 4096 characters', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const longText = 'x'.repeat(5000); + await channel.sendMessage('tg:100200300', longText); + + expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(2); + expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith( + 1, + '100200300', + 'x'.repeat(4096), + ); + expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith( + 2, + '100200300', + 'x'.repeat(904), + ); + }); + + it('sends exactly one message at 4096 characters', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const exactText = 'y'.repeat(4096); + await channel.sendMessage('tg:100200300', exactText); + + expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(1); + }); + + it('handles send failure gracefully', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + currentBot().api.sendMessage.mockRejectedValueOnce( + new Error('Network error'), + ); + + // Should not throw + await expect( + channel.sendMessage('tg:100200300', 'Will fail'), + ).resolves.toBeUndefined(); + }); + + it('does nothing when bot is not initialized', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + // Don't connect — bot is null + await channel.sendMessage('tg:100200300', 'No bot'); + + // No error, no API call + }); + }); + + // --- ownsJid --- + + describe('ownsJid', () => { + it('owns tg: JIDs', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.ownsJid('tg:123456')).toBe(true); + }); + + it('owns tg: JIDs with negative IDs (groups)', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.ownsJid('tg:-1001234567890')).toBe(true); + }); + + it('does not own WhatsApp group JIDs', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.ownsJid('12345@g.us')).toBe(false); + }); + + it('does not own WhatsApp DM JIDs', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(false); + }); + + it('does not own unknown JID formats', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.ownsJid('random-string')).toBe(false); + }); + }); + + // --- setTyping --- + + describe('setTyping', () => { + it('sends typing action when isTyping is true', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + await channel.setTyping('tg:100200300', true); + + expect(currentBot().api.sendChatAction).toHaveBeenCalledWith( + '100200300', + 'typing', + ); + }); + + it('does nothing when isTyping is false', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + await channel.setTyping('tg:100200300', false); + + expect(currentBot().api.sendChatAction).not.toHaveBeenCalled(); + }); + + it('does nothing when bot is not initialized', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + // Don't connect + await channel.setTyping('tg:100200300', true); + + // No error, no API call + }); + + it('handles typing indicator failure gracefully', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + currentBot().api.sendChatAction.mockRejectedValueOnce( + new Error('Rate limited'), + ); + + await expect( + channel.setTyping('tg:100200300', true), + ).resolves.toBeUndefined(); + }); + }); + + // --- Bot commands --- + + describe('bot commands', () => { + it('/chatid replies with chat ID and metadata', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const handler = currentBot().commandHandlers.get('chatid')!; + const ctx = { + chat: { id: 100200300, type: 'group' as const }, + from: { first_name: 'Alice' }, + reply: vi.fn(), + }; + + await handler(ctx); + + expect(ctx.reply).toHaveBeenCalledWith( + expect.stringContaining('tg:100200300'), + expect.objectContaining({ parse_mode: 'Markdown' }), + ); + }); + + it('/chatid shows chat type', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const handler = currentBot().commandHandlers.get('chatid')!; + const ctx = { + chat: { id: 555, type: 'private' as const }, + from: { first_name: 'Bob' }, + reply: vi.fn(), + }; + + await handler(ctx); + + expect(ctx.reply).toHaveBeenCalledWith( + expect.stringContaining('private'), + expect.any(Object), + ); + }); + + it('/ping replies with bot status', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const handler = currentBot().commandHandlers.get('ping')!; + const ctx = { reply: vi.fn() }; + + await handler(ctx); + + expect(ctx.reply).toHaveBeenCalledWith('Andy is online.'); + }); + }); + + // --- Channel properties --- + + describe('channel properties', () => { + it('has name "telegram"', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.name).toBe('telegram'); + }); + }); +}); diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts new file mode 100644 index 0000000..ebd4d2c --- /dev/null +++ b/src/channels/telegram.ts @@ -0,0 +1,366 @@ +import fs from 'fs'; +import path from 'path'; + +import { Bot } from 'grammy'; + +import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js'; +import { resolveGroupIpcPath } from '../group-folder.js'; +import { logger } from '../logger.js'; +import { transcribeBuffer } from '../transcription.js'; +import { + Channel, + OnChatMetadata, + OnInboundMessage, + RegisteredGroup, +} from '../types.js'; + +export interface TelegramChannelOpts { + onMessage: OnInboundMessage; + onChatMetadata: OnChatMetadata; + registeredGroups: () => Record; + clearSession: (chatJid: string) => void; +} + +export class TelegramChannel implements Channel { + name = 'telegram'; + + private bot: Bot | null = null; + private opts: TelegramChannelOpts; + private botToken: string; + + constructor(botToken: string, opts: TelegramChannelOpts) { + this.botToken = botToken; + this.opts = opts; + } + + /** + * Build a JID from a Telegram chat ID and optional topic thread ID. + * Format: "tg:{chatId}" or "tg:{chatId}:{threadId}" for forum topics. + */ + private buildJid(chatId: number, threadId?: number): string { + return threadId ? `tg:${chatId}:${threadId}` : `tg:${chatId}`; + } + + /** + * Parse a JID back into chatId (string) and optional threadId (number). + * Handles negative chat IDs (groups/supergroups start with -100...). + */ + private parseJid(jid: string): { chatId: string; threadId?: number } { + const withoutPrefix = jid.replace(/^tg:/, ''); + const colonIdx = withoutPrefix.indexOf(':'); + if (colonIdx === -1) { + return { chatId: withoutPrefix }; + } + return { + chatId: withoutPrefix.slice(0, colonIdx), + threadId: parseInt(withoutPrefix.slice(colonIdx + 1), 10), + }; + } + + async connect(): Promise { + this.bot = new Bot(this.botToken); + + // Command to get chat ID (useful for registration) + // In forum topics, also shows the thread ID so the full JID can be used. + this.bot.command('chatid', (ctx) => { + const chatId = ctx.chat.id; + const chatType = ctx.chat.type; + const threadId = (ctx.message as any)?.message_thread_id as + | number + | undefined; + const chatJid = this.buildJid(chatId, threadId); + const chatName = + chatType === 'private' + ? ctx.from?.first_name || 'Private' + : (ctx.chat as any).title || 'Unknown'; + + const topicLine = threadId ? `\nThread ID: \`${threadId}\`` : ''; + ctx.reply( + `Chat ID: \`${chatJid}\`${topicLine}\nName: ${chatName}\nType: ${chatType}`, + { parse_mode: 'Markdown' }, + ); + }); + + // Command to check bot status + this.bot.command('ping', (ctx) => { + ctx.reply(`${ASSISTANT_NAME} is online.`); + }); + + // Command to clear conversation context (start a new session) + this.bot.command('reset', (ctx) => { + const threadId = (ctx.message as any)?.message_thread_id as + | number + | undefined; + const chatJid = this.buildJid(ctx.chat.id, threadId); + this.opts.clearSession(chatJid); + ctx.reply('Session cleared. Next message starts a fresh conversation.'); + }); + + this.bot.on('message:text', async (ctx) => { + // Skip commands + if (ctx.message.text.startsWith('/')) return; + + const threadId = ctx.message.message_thread_id; + const chatJid = this.buildJid(ctx.chat.id, threadId); + let content = ctx.message.text; + const timestamp = new Date(ctx.message.date * 1000).toISOString(); + const senderName = + ctx.from?.first_name || + ctx.from?.username || + ctx.from?.id.toString() || + 'Unknown'; + const sender = ctx.from?.id.toString() || ''; + const msgId = ctx.message.message_id.toString(); + + // Determine chat name + const chatName = + ctx.chat.type === 'private' + ? senderName + : (ctx.chat as any).title || chatJid; + + // In private DMs every message is implicitly addressed to the bot — + // no @mention entity exists. Prepend the trigger so TRIGGER_PATTERN matches. + if (ctx.chat.type === 'private' && !TRIGGER_PATTERN.test(content)) { + content = `@${ASSISTANT_NAME} ${content}`; + } + + // Translate Telegram @bot_username mentions into TRIGGER_PATTERN format. + // Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN + // (e.g., ^@Andy\b), so we prepend the trigger when the bot is @mentioned. + const botUsername = ctx.me?.username?.toLowerCase(); + if (botUsername) { + const entities = ctx.message.entities || []; + const isBotMentioned = entities.some((entity) => { + if (entity.type === 'mention') { + const mentionText = content + .substring(entity.offset, entity.offset + entity.length) + .toLowerCase(); + return mentionText === `@${botUsername}`; + } + return false; + }); + if (isBotMentioned && !TRIGGER_PATTERN.test(content)) { + content = `@${ASSISTANT_NAME} ${content}`; + } + } + + // Store chat metadata for discovery + this.opts.onChatMetadata(chatJid, timestamp, chatName); + + // Only deliver full message for registered groups + const group = this.opts.registeredGroups()[chatJid]; + if (!group) { + logger.debug( + { chatJid, chatName }, + 'Message from unregistered Telegram chat', + ); + return; + } + + // Deliver message — startMessageLoop() will pick it up + this.opts.onMessage(chatJid, { + id: msgId, + chat_jid: chatJid, + sender, + sender_name: senderName, + content, + timestamp, + is_from_me: false, + }); + + logger.info( + { chatJid, chatName, sender: senderName }, + 'Telegram message stored', + ); + }); + + // Handle non-text messages with placeholders so the agent knows something was sent + const storeNonText = (ctx: any, placeholder: string) => { + const threadId = ctx.message?.message_thread_id as number | undefined; + const chatJid = this.buildJid(ctx.chat.id, threadId); + const group = this.opts.registeredGroups()[chatJid]; + if (!group) return; + + const timestamp = new Date(ctx.message.date * 1000).toISOString(); + const senderName = + ctx.from?.first_name || + ctx.from?.username || + ctx.from?.id?.toString() || + 'Unknown'; + const caption = ctx.message.caption ? ` ${ctx.message.caption}` : ''; + + this.opts.onChatMetadata(chatJid, timestamp); + this.opts.onMessage(chatJid, { + id: ctx.message.message_id.toString(), + chat_jid: chatJid, + sender: ctx.from?.id?.toString() || '', + sender_name: senderName, + content: `${placeholder}${caption}`, + timestamp, + is_from_me: false, + }); + }; + + this.bot.on('message:photo', async (ctx) => { + const threadId = ctx.message.message_thread_id; + const chatJid = this.buildJid(ctx.chat.id, threadId); + const group = this.opts.registeredGroups()[chatJid]; + if (!group) return; + + let placeholder = '[Photo]'; + try { + // Highest resolution is last in the array + const photos = ctx.message.photo; + const largest = photos[photos.length - 1]; + const file = await ctx.api.getFile(largest.file_id); + const url = `https://api.telegram.org/file/bot${this.botToken}/${file.file_path}`; + const res = await fetch(url); + if (res.ok) { + const buffer = Buffer.from(await res.arrayBuffer()); + const ext = path.extname(file.file_path || '') || '.jpg'; + const filename = `photo_${ctx.message.message_id}${ext}`; + const inputDir = path.join( + resolveGroupIpcPath(group.folder), + 'input', + ); + fs.mkdirSync(inputDir, { recursive: true }); + fs.writeFileSync(path.join(inputDir, filename), buffer); + placeholder = `[Photo: /workspace/ipc/input/${filename} — use the Read tool to view this image]`; + logger.info( + { chatJid, bytes: buffer.length }, + 'Downloaded Telegram photo', + ); + } else { + logger.error( + { chatJid, status: res.status }, + 'Failed to download Telegram photo', + ); + } + } catch (err) { + logger.error({ err }, 'Telegram photo download error'); + } + storeNonText(ctx, placeholder); + }); + this.bot.on('message:video', (ctx) => storeNonText(ctx, '[Video]')); + this.bot.on('message:voice', async (ctx) => { + const threadId = ctx.message.message_thread_id; + const chatJid = this.buildJid(ctx.chat.id, threadId); + const group = this.opts.registeredGroups()[chatJid]; + if (!group) return; + + let placeholder = '[Voice Message - transcription unavailable]'; + try { + const file = await ctx.getFile(); + const url = `https://api.telegram.org/file/bot${this.botToken}/${file.file_path}`; + const res = await fetch(url); + if (res.ok) { + const buffer = Buffer.from(await res.arrayBuffer()); + logger.info( + { chatJid, bytes: buffer.length }, + 'Downloaded Telegram voice file', + ); + const transcript = await transcribeBuffer(buffer); + if (transcript) { + placeholder = `[Voice: ${transcript}]`; + logger.info( + { chatJid, length: transcript.length }, + 'Transcribed voice message', + ); + } else { + logger.warn({ chatJid }, 'Voice transcription returned null'); + } + } else { + logger.error( + { chatJid, status: res.status }, + 'Failed to download Telegram voice file', + ); + } + } catch (err) { + logger.error({ err }, 'Telegram voice transcription error'); + placeholder = '[Voice Message - transcription failed]'; + } + storeNonText(ctx, placeholder); + }); + this.bot.on('message:audio', (ctx) => storeNonText(ctx, '[Audio]')); + this.bot.on('message:document', (ctx) => { + const name = ctx.message.document?.file_name || 'file'; + storeNonText(ctx, `[Document: ${name}]`); + }); + this.bot.on('message:sticker', (ctx) => { + const emoji = ctx.message.sticker?.emoji || ''; + storeNonText(ctx, `[Sticker ${emoji}]`); + }); + this.bot.on('message:location', (ctx) => storeNonText(ctx, '[Location]')); + this.bot.on('message:contact', (ctx) => storeNonText(ctx, '[Contact]')); + + // Handle errors gracefully + this.bot.catch((err) => { + logger.error({ err: err.message }, 'Telegram bot error'); + }); + + // Start polling — returns a Promise that resolves when started + return new Promise((resolve) => { + this.bot!.start({ + onStart: (botInfo) => { + logger.info( + { username: botInfo.username, id: botInfo.id }, + 'Telegram bot connected', + ); + console.log(`\n Telegram bot: @${botInfo.username}`); + console.log( + ` Send /chatid to the bot to get a chat's registration ID\n`, + ); + resolve(); + }, + }); + }); + } + + async sendMessage(jid: string, text: string): Promise { + if (!this.bot) { + logger.warn('Telegram bot not initialized'); + return; + } + + try { + const { chatId, threadId } = this.parseJid(jid); + + // Telegram has a 4096 character limit per message — split if needed + const MAX_LENGTH = 4096; + const opts = threadId ? { message_thread_id: threadId } : {}; + for (let i = 0; i < text.length; i += MAX_LENGTH) { + await this.bot.api.sendMessage(chatId, text.slice(i, i + MAX_LENGTH), opts); + } + logger.info({ jid, length: text.length }, 'Telegram message sent'); + } catch (err) { + logger.error({ jid, err }, 'Failed to send Telegram message'); + } + } + + isConnected(): boolean { + return this.bot !== null; + } + + ownsJid(jid: string): boolean { + return jid.startsWith('tg:'); + } + + async disconnect(): Promise { + if (this.bot) { + this.bot.stop(); + this.bot = null; + logger.info('Telegram bot stopped'); + } + } + + async setTyping(jid: string, isTyping: boolean): Promise { + if (!this.bot || !isTyping) return; + try { + const { chatId, threadId } = this.parseJid(jid); + const opts = threadId ? { message_thread_id: threadId } : {}; + await this.bot.api.sendChatAction(chatId, 'typing', opts); + } catch (err) { + logger.debug({ jid, err }, 'Failed to send Telegram typing indicator'); + } + } +} diff --git a/src/channels/whatsapp.test.ts b/src/channels/whatsapp.test.ts new file mode 100644 index 0000000..3c005ea --- /dev/null +++ b/src/channels/whatsapp.test.ts @@ -0,0 +1,1032 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; + +// --- Mocks --- + +// Mock config +vi.mock('../config.js', () => ({ + STORE_DIR: '/tmp/nanoclaw-test-store', + ASSISTANT_NAME: 'Andy', + ASSISTANT_HAS_OWN_NUMBER: false, +})); + +// Mock logger +vi.mock('../logger.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock db +vi.mock('../db.js', () => ({ + getLastGroupSync: vi.fn(() => null), + setLastGroupSync: vi.fn(), + updateChatName: vi.fn(), +})); + +// Mock transcription +vi.mock('../transcription.js', () => ({ + isVoiceMessage: vi.fn((msg: any) => msg.message?.audioMessage?.ptt === true), + transcribeAudioMessage: vi + .fn() + .mockResolvedValue('Hello this is a voice message'), +})); + +// Mock fs +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + default: { + ...actual, + existsSync: vi.fn(() => true), + mkdirSync: vi.fn(), + }, + }; +}); + +// Mock child_process (used for osascript notification) +vi.mock('child_process', () => ({ + exec: vi.fn(), +})); + +// Build a fake WASocket that's an EventEmitter with the methods we need +function createFakeSocket() { + const ev = new EventEmitter(); + const sock = { + ev: { + on: (event: string, handler: (...args: unknown[]) => void) => { + ev.on(event, handler); + }, + }, + user: { + id: '1234567890:1@s.whatsapp.net', + lid: '9876543210:1@lid', + }, + sendMessage: vi.fn().mockResolvedValue(undefined), + sendPresenceUpdate: vi.fn().mockResolvedValue(undefined), + groupFetchAllParticipating: vi.fn().mockResolvedValue({}), + end: vi.fn(), + // Expose the event emitter for triggering events in tests + _ev: ev, + }; + return sock; +} + +let fakeSocket: ReturnType; + +// Mock Baileys +vi.mock('@whiskeysockets/baileys', () => { + return { + default: vi.fn(() => fakeSocket), + Browsers: { macOS: vi.fn(() => ['macOS', 'Chrome', '']) }, + DisconnectReason: { + loggedOut: 401, + badSession: 500, + connectionClosed: 428, + connectionLost: 408, + connectionReplaced: 440, + timedOut: 408, + restartRequired: 515, + }, + fetchLatestWaWebVersion: vi + .fn() + .mockResolvedValue({ version: [2, 3000, 0] }), + makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys), + useMultiFileAuthState: vi.fn().mockResolvedValue({ + state: { + creds: {}, + keys: {}, + }, + saveCreds: vi.fn(), + }), + }; +}); + +import { WhatsAppChannel, WhatsAppChannelOpts } from './whatsapp.js'; +import { getLastGroupSync, updateChatName, setLastGroupSync } from '../db.js'; +import { transcribeAudioMessage } from '../transcription.js'; + +// --- Test helpers --- + +function createTestOpts( + overrides?: Partial, +): WhatsAppChannelOpts { + return { + onMessage: vi.fn(), + onChatMetadata: vi.fn(), + registeredGroups: vi.fn(() => ({ + 'registered@g.us': { + name: 'Test Group', + folder: 'test-group', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + })), + ...overrides, + }; +} + +function triggerConnection(state: string, extra?: Record) { + fakeSocket._ev.emit('connection.update', { connection: state, ...extra }); +} + +function triggerDisconnect(statusCode: number) { + fakeSocket._ev.emit('connection.update', { + connection: 'close', + lastDisconnect: { + error: { output: { statusCode } }, + }, + }); +} + +async function triggerMessages(messages: unknown[]) { + fakeSocket._ev.emit('messages.upsert', { messages }); + // Flush microtasks so the async messages.upsert handler completes + await new Promise((r) => setTimeout(r, 0)); +} + +// --- Tests --- + +describe('WhatsAppChannel', () => { + beforeEach(() => { + fakeSocket = createFakeSocket(); + vi.mocked(getLastGroupSync).mockReturnValue(null); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + /** + * Helper: start connect, flush microtasks so event handlers are registered, + * then trigger the connection open event. Returns the resolved promise. + */ + async function connectChannel(channel: WhatsAppChannel): Promise { + const p = channel.connect(); + // Flush microtasks so connectInternal completes its await and registers handlers + await new Promise((r) => setTimeout(r, 0)); + triggerConnection('open'); + return p; + } + + // --- Version fetch --- + + describe('version fetch', () => { + it('connects with fetched version', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + await connectChannel(channel); + + const { fetchLatestWaWebVersion } = + await import('@whiskeysockets/baileys'); + expect(fetchLatestWaWebVersion).toHaveBeenCalledWith({}); + }); + + it('falls back gracefully when version fetch fails', async () => { + const { fetchLatestWaWebVersion } = + await import('@whiskeysockets/baileys'); + vi.mocked(fetchLatestWaWebVersion).mockRejectedValueOnce( + new Error('network error'), + ); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + await connectChannel(channel); + + // Should still connect successfully despite fetch failure + expect(channel.isConnected()).toBe(true); + }); + }); + + // --- Connection lifecycle --- + + describe('connection lifecycle', () => { + it('resolves connect() when connection opens', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + expect(channel.isConnected()).toBe(true); + }); + + it('sets up LID to phone mapping on open', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // The channel should have mapped the LID from sock.user + // We can verify by sending a message from a LID JID + // and checking the translated JID in the callback + }); + + it('flushes outgoing queue on reconnect', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // Disconnect + (channel as any).connected = false; + + // Queue a message while disconnected + await channel.sendMessage('test@g.us', 'Queued message'); + expect(fakeSocket.sendMessage).not.toHaveBeenCalled(); + + // Reconnect + (channel as any).connected = true; + await (channel as any).flushOutgoingQueue(); + + // Group messages get prefixed when flushed + expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { + text: 'Andy: Queued message', + }); + }); + + it('disconnects cleanly', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await channel.disconnect(); + expect(channel.isConnected()).toBe(false); + expect(fakeSocket.end).toHaveBeenCalled(); + }); + }); + + // --- QR code and auth --- + + describe('authentication', () => { + it('exits process when QR code is emitted (no auth state)', async () => { + vi.useFakeTimers(); + const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + // Start connect but don't await (it won't resolve - process exits) + channel.connect().catch(() => {}); + + // Flush microtasks so connectInternal registers handlers + await vi.advanceTimersByTimeAsync(0); + + // Emit QR code event + fakeSocket._ev.emit('connection.update', { qr: 'some-qr-data' }); + + // Advance timer past the 1000ms setTimeout before exit + await vi.advanceTimersByTimeAsync(1500); + + expect(mockExit).toHaveBeenCalledWith(1); + mockExit.mockRestore(); + vi.useRealTimers(); + }); + }); + + // --- Reconnection behavior --- + + describe('reconnection', () => { + it('reconnects on non-loggedOut disconnect', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + expect(channel.isConnected()).toBe(true); + + // Disconnect with a non-loggedOut reason (e.g., connectionClosed = 428) + triggerDisconnect(428); + + expect(channel.isConnected()).toBe(false); + // The channel should attempt to reconnect (calls connectInternal again) + }); + + it('exits on loggedOut disconnect', async () => { + const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // Disconnect with loggedOut reason (401) + triggerDisconnect(401); + + expect(channel.isConnected()).toBe(false); + expect(mockExit).toHaveBeenCalledWith(0); + mockExit.mockRestore(); + }); + + it('retries reconnection after 5s on failure', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // Disconnect with stream error 515 + triggerDisconnect(515); + + // The channel sets a 5s retry — just verify it doesn't crash + await new Promise((r) => setTimeout(r, 100)); + }); + }); + + // --- Message handling --- + + describe('message handling', () => { + it('delivers message for registered group', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-1', + remoteJid: 'registered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { conversation: 'Hello Andy' }, + pushName: 'Alice', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'registered@g.us', + expect.any(String), + undefined, + 'whatsapp', + true, + ); + expect(opts.onMessage).toHaveBeenCalledWith( + 'registered@g.us', + expect.objectContaining({ + id: 'msg-1', + content: 'Hello Andy', + sender_name: 'Alice', + is_from_me: false, + }), + ); + }); + + it('only emits metadata for unregistered groups', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-2', + remoteJid: 'unregistered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { conversation: 'Hello' }, + pushName: 'Bob', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'unregistered@g.us', + expect.any(String), + undefined, + 'whatsapp', + true, + ); + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + + it('ignores status@broadcast messages', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-3', + remoteJid: 'status@broadcast', + fromMe: false, + }, + message: { conversation: 'Status update' }, + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onChatMetadata).not.toHaveBeenCalled(); + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + + it('ignores messages with no content', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-4', + remoteJid: 'registered@g.us', + fromMe: false, + }, + message: null, + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + + it('extracts text from extendedTextMessage', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-5', + remoteJid: 'registered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { + extendedTextMessage: { text: 'A reply message' }, + }, + pushName: 'Charlie', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'registered@g.us', + expect.objectContaining({ content: 'A reply message' }), + ); + }); + + it('extracts caption from imageMessage', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-6', + remoteJid: 'registered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { + imageMessage: { + caption: 'Check this photo', + mimetype: 'image/jpeg', + }, + }, + pushName: 'Diana', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'registered@g.us', + expect.objectContaining({ content: 'Check this photo' }), + ); + }); + + it('extracts caption from videoMessage', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-7', + remoteJid: 'registered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { + videoMessage: { caption: 'Watch this', mimetype: 'video/mp4' }, + }, + pushName: 'Eve', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'registered@g.us', + expect.objectContaining({ content: 'Watch this' }), + ); + }); + + it('transcribes voice messages', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-8', + remoteJid: 'registered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { + audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true }, + }, + pushName: 'Frank', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(transcribeAudioMessage).toHaveBeenCalled(); + expect(opts.onMessage).toHaveBeenCalledTimes(1); + expect(opts.onMessage).toHaveBeenCalledWith( + 'registered@g.us', + expect.objectContaining({ + content: '[Voice: Hello this is a voice message]', + }), + ); + }); + + it('falls back when transcription returns null', async () => { + vi.mocked(transcribeAudioMessage).mockResolvedValueOnce(null); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-8b', + remoteJid: 'registered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { + audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true }, + }, + pushName: 'Frank', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onMessage).toHaveBeenCalledTimes(1); + expect(opts.onMessage).toHaveBeenCalledWith( + 'registered@g.us', + expect.objectContaining({ + content: '[Voice Message - transcription unavailable]', + }), + ); + }); + + it('falls back when transcription throws', async () => { + vi.mocked(transcribeAudioMessage).mockRejectedValueOnce( + new Error('API error'), + ); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-8c', + remoteJid: 'registered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { + audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true }, + }, + pushName: 'Frank', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onMessage).toHaveBeenCalledTimes(1); + expect(opts.onMessage).toHaveBeenCalledWith( + 'registered@g.us', + expect.objectContaining({ + content: '[Voice Message - transcription failed]', + }), + ); + }); + + it('uses sender JID when pushName is absent', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-9', + remoteJid: 'registered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { conversation: 'No push name' }, + // pushName is undefined + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'registered@g.us', + expect.objectContaining({ sender_name: '5551234' }), + ); + }); + }); + + // --- LID ↔ JID translation --- + + describe('LID to JID translation', () => { + it('translates known LID to phone JID', async () => { + const opts = createTestOpts({ + registeredGroups: vi.fn(() => ({ + '1234567890@s.whatsapp.net': { + name: 'Self Chat', + folder: 'self-chat', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + })), + }); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // The socket has lid '9876543210:1@lid' → phone '1234567890@s.whatsapp.net' + // Send a message from the LID + await triggerMessages([ + { + key: { + id: 'msg-lid', + remoteJid: '9876543210@lid', + fromMe: false, + }, + message: { conversation: 'From LID' }, + pushName: 'Self', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + // Should be translated to phone JID + expect(opts.onChatMetadata).toHaveBeenCalledWith( + '1234567890@s.whatsapp.net', + expect.any(String), + undefined, + 'whatsapp', + false, + ); + }); + + it('passes through non-LID JIDs unchanged', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-normal', + remoteJid: 'registered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { conversation: 'Normal JID' }, + pushName: 'Grace', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'registered@g.us', + expect.any(String), + undefined, + 'whatsapp', + true, + ); + }); + + it('passes through unknown LID JIDs unchanged', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-unknown-lid', + remoteJid: '0000000000@lid', + fromMe: false, + }, + message: { conversation: 'Unknown LID' }, + pushName: 'Unknown', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + // Unknown LID passes through unchanged + expect(opts.onChatMetadata).toHaveBeenCalledWith( + '0000000000@lid', + expect.any(String), + undefined, + 'whatsapp', + false, + ); + }); + }); + + // --- Outgoing message queue --- + + describe('outgoing message queue', () => { + it('sends message directly when connected', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await channel.sendMessage('test@g.us', 'Hello'); + // Group messages get prefixed with assistant name + expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { + text: 'Andy: Hello', + }); + }); + + it('prefixes direct chat messages on shared number', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await channel.sendMessage('123@s.whatsapp.net', 'Hello'); + // Shared number: DMs also get prefixed (needed for self-chat distinction) + expect(fakeSocket.sendMessage).toHaveBeenCalledWith( + '123@s.whatsapp.net', + { text: 'Andy: Hello' }, + ); + }); + + it('queues message when disconnected', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + // Don't connect — channel starts disconnected + await channel.sendMessage('test@g.us', 'Queued'); + expect(fakeSocket.sendMessage).not.toHaveBeenCalled(); + }); + + it('queues message on send failure', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // Make sendMessage fail + fakeSocket.sendMessage.mockRejectedValueOnce(new Error('Network error')); + + await channel.sendMessage('test@g.us', 'Will fail'); + + // Should not throw, message queued for retry + // The queue should have the message + }); + + it('flushes multiple queued messages in order', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + // Queue messages while disconnected + await channel.sendMessage('test@g.us', 'First'); + await channel.sendMessage('test@g.us', 'Second'); + await channel.sendMessage('test@g.us', 'Third'); + + // Connect — flush happens automatically on open + await connectChannel(channel); + + // Give the async flush time to complete + await new Promise((r) => setTimeout(r, 50)); + + expect(fakeSocket.sendMessage).toHaveBeenCalledTimes(3); + // Group messages get prefixed + expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', { + text: 'Andy: First', + }); + expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', { + text: 'Andy: Second', + }); + expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', { + text: 'Andy: Third', + }); + }); + }); + + // --- Group metadata sync --- + + describe('group metadata sync', () => { + it('syncs group metadata on first connection', async () => { + fakeSocket.groupFetchAllParticipating.mockResolvedValue({ + 'group1@g.us': { subject: 'Group One' }, + 'group2@g.us': { subject: 'Group Two' }, + }); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // Wait for async sync to complete + await new Promise((r) => setTimeout(r, 50)); + + expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled(); + expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Group One'); + expect(updateChatName).toHaveBeenCalledWith('group2@g.us', 'Group Two'); + expect(setLastGroupSync).toHaveBeenCalled(); + }); + + it('skips sync when synced recently', async () => { + // Last sync was 1 hour ago (within 24h threshold) + vi.mocked(getLastGroupSync).mockReturnValue( + new Date(Date.now() - 60 * 60 * 1000).toISOString(), + ); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await new Promise((r) => setTimeout(r, 50)); + + expect(fakeSocket.groupFetchAllParticipating).not.toHaveBeenCalled(); + }); + + it('forces sync regardless of cache', async () => { + vi.mocked(getLastGroupSync).mockReturnValue( + new Date(Date.now() - 60 * 60 * 1000).toISOString(), + ); + + fakeSocket.groupFetchAllParticipating.mockResolvedValue({ + 'group@g.us': { subject: 'Forced Group' }, + }); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await channel.syncGroupMetadata(true); + + expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled(); + expect(updateChatName).toHaveBeenCalledWith('group@g.us', 'Forced Group'); + }); + + it('handles group sync failure gracefully', async () => { + fakeSocket.groupFetchAllParticipating.mockRejectedValue( + new Error('Network timeout'), + ); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // Should not throw + await expect(channel.syncGroupMetadata(true)).resolves.toBeUndefined(); + }); + + it('skips groups with no subject', async () => { + fakeSocket.groupFetchAllParticipating.mockResolvedValue({ + 'group1@g.us': { subject: 'Has Subject' }, + 'group2@g.us': { subject: '' }, + 'group3@g.us': {}, + }); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // Clear any calls from the automatic sync on connect + vi.mocked(updateChatName).mockClear(); + + await channel.syncGroupMetadata(true); + + expect(updateChatName).toHaveBeenCalledTimes(1); + expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Has Subject'); + }); + }); + + // --- JID ownership --- + + describe('ownsJid', () => { + it('owns @g.us JIDs (WhatsApp groups)', () => { + const channel = new WhatsAppChannel(createTestOpts()); + expect(channel.ownsJid('12345@g.us')).toBe(true); + }); + + it('owns @s.whatsapp.net JIDs (WhatsApp DMs)', () => { + const channel = new WhatsAppChannel(createTestOpts()); + expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(true); + }); + + it('does not own Telegram JIDs', () => { + const channel = new WhatsAppChannel(createTestOpts()); + expect(channel.ownsJid('tg:12345')).toBe(false); + }); + + it('does not own unknown JID formats', () => { + const channel = new WhatsAppChannel(createTestOpts()); + expect(channel.ownsJid('random-string')).toBe(false); + }); + }); + + // --- Typing indicator --- + + describe('setTyping', () => { + it('sends composing presence when typing', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await channel.setTyping('test@g.us', true); + expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith( + 'composing', + 'test@g.us', + ); + }); + + it('sends paused presence when stopping', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await channel.setTyping('test@g.us', false); + expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith( + 'paused', + 'test@g.us', + ); + }); + + it('handles typing indicator failure gracefully', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + fakeSocket.sendPresenceUpdate.mockRejectedValueOnce(new Error('Failed')); + + // Should not throw + await expect( + channel.setTyping('test@g.us', true), + ).resolves.toBeUndefined(); + }); + }); + + // --- Channel properties --- + + describe('channel properties', () => { + it('has name "whatsapp"', () => { + const channel = new WhatsAppChannel(createTestOpts()); + expect(channel.name).toBe('whatsapp'); + }); + + it('does not expose prefixAssistantName (prefix handled internally)', () => { + const channel = new WhatsAppChannel(createTestOpts()); + expect('prefixAssistantName' in channel).toBe(false); + }); + }); +}); diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts new file mode 100644 index 0000000..b875417 --- /dev/null +++ b/src/channels/whatsapp.ts @@ -0,0 +1,400 @@ +import { exec } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import makeWASocket, { + Browsers, + DisconnectReason, + WASocket, + fetchLatestWaWebVersion, + makeCacheableSignalKeyStore, + useMultiFileAuthState, +} from '@whiskeysockets/baileys'; + +import { + ASSISTANT_HAS_OWN_NUMBER, + ASSISTANT_NAME, + STORE_DIR, +} from '../config.js'; +import { getLastGroupSync, setLastGroupSync, updateChatName } from '../db.js'; +import { logger } from '../logger.js'; +import { isVoiceMessage, transcribeAudioMessage } from '../transcription.js'; +import { + Channel, + OnInboundMessage, + OnChatMetadata, + RegisteredGroup, +} from '../types.js'; + +const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours + +export interface WhatsAppChannelOpts { + onMessage: OnInboundMessage; + onChatMetadata: OnChatMetadata; + registeredGroups: () => Record; +} + +export class WhatsAppChannel implements Channel { + name = 'whatsapp'; + + private sock!: WASocket; + private connected = false; + private lidToPhoneMap: Record = {}; + private outgoingQueue: Array<{ jid: string; text: string }> = []; + private flushing = false; + private groupSyncTimerStarted = false; + + private opts: WhatsAppChannelOpts; + + constructor(opts: WhatsAppChannelOpts) { + this.opts = opts; + } + + async connect(): Promise { + return new Promise((resolve, reject) => { + this.connectInternal(resolve).catch(reject); + }); + } + + private async connectInternal(onFirstOpen?: () => void): Promise { + const authDir = path.join(STORE_DIR, 'auth'); + fs.mkdirSync(authDir, { recursive: true }); + + const { state, saveCreds } = await useMultiFileAuthState(authDir); + + const { version } = await fetchLatestWaWebVersion({}).catch((err) => { + logger.warn( + { err }, + 'Failed to fetch latest WA Web version, using default', + ); + return { version: undefined }; + }); + this.sock = makeWASocket({ + version, + auth: { + creds: state.creds, + keys: makeCacheableSignalKeyStore(state.keys, logger), + }, + printQRInTerminal: false, + logger, + browser: Browsers.macOS('Chrome'), + }); + + this.sock.ev.on('connection.update', (update) => { + const { connection, lastDisconnect, qr } = update; + + if (qr) { + const msg = + 'WhatsApp authentication required. Run /setup in Claude Code.'; + logger.error(msg); + exec( + `osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`, + ); + setTimeout(() => process.exit(1), 1000); + } + + if (connection === 'close') { + this.connected = false; + const reason = ( + lastDisconnect?.error as { output?: { statusCode?: number } } + )?.output?.statusCode; + const shouldReconnect = reason !== DisconnectReason.loggedOut; + logger.info( + { + reason, + shouldReconnect, + queuedMessages: this.outgoingQueue.length, + }, + 'Connection closed', + ); + + if (shouldReconnect) { + logger.info('Reconnecting...'); + this.connectInternal().catch((err) => { + logger.error({ err }, 'Failed to reconnect, retrying in 5s'); + setTimeout(() => { + this.connectInternal().catch((err2) => { + logger.error({ err: err2 }, 'Reconnection retry failed'); + }); + }, 5000); + }); + } else { + logger.info('Logged out. Run /setup to re-authenticate.'); + process.exit(0); + } + } else if (connection === 'open') { + this.connected = true; + logger.info('Connected to WhatsApp'); + + // Announce availability so WhatsApp relays subsequent presence updates (typing indicators) + this.sock.sendPresenceUpdate('available').catch((err) => { + logger.warn({ err }, 'Failed to send presence update'); + }); + + // Build LID to phone mapping from auth state for self-chat translation + if (this.sock.user) { + const phoneUser = this.sock.user.id.split(':')[0]; + const lidUser = this.sock.user.lid?.split(':')[0]; + if (lidUser && phoneUser) { + this.lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`; + logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set'); + } + } + + // Flush any messages queued while disconnected + this.flushOutgoingQueue().catch((err) => + logger.error({ err }, 'Failed to flush outgoing queue'), + ); + + // Sync group metadata on startup (respects 24h cache) + this.syncGroupMetadata().catch((err) => + logger.error({ err }, 'Initial group sync failed'), + ); + // Set up daily sync timer (only once) + if (!this.groupSyncTimerStarted) { + this.groupSyncTimerStarted = true; + setInterval(() => { + this.syncGroupMetadata().catch((err) => + logger.error({ err }, 'Periodic group sync failed'), + ); + }, GROUP_SYNC_INTERVAL_MS); + } + + // Signal first connection to caller + if (onFirstOpen) { + onFirstOpen(); + onFirstOpen = undefined; + } + } + }); + + this.sock.ev.on('creds.update', saveCreds); + + this.sock.ev.on('messages.upsert', async ({ messages }) => { + for (const msg of messages) { + if (!msg.message) continue; + const rawJid = msg.key.remoteJid; + if (!rawJid || rawJid === 'status@broadcast') continue; + + // Translate LID JID to phone JID if applicable + const chatJid = await this.translateJid(rawJid); + + const timestamp = new Date( + Number(msg.messageTimestamp) * 1000, + ).toISOString(); + + // Always notify about chat metadata for group discovery + const isGroup = chatJid.endsWith('@g.us'); + this.opts.onChatMetadata( + chatJid, + timestamp, + undefined, + 'whatsapp', + isGroup, + ); + + // Only deliver full message for registered groups + const groups = this.opts.registeredGroups(); + if (groups[chatJid]) { + const content = + msg.message?.conversation || + msg.message?.extendedTextMessage?.text || + msg.message?.imageMessage?.caption || + msg.message?.videoMessage?.caption || + ''; + + // Skip protocol messages with no text content (encryption keys, read receipts, etc.) + // but allow voice messages through for transcription + if (!content && !isVoiceMessage(msg)) continue; + + const sender = msg.key.participant || msg.key.remoteJid || ''; + const senderName = msg.pushName || sender.split('@')[0]; + + const fromMe = msg.key.fromMe || false; + // Detect bot messages: with own number, fromMe is reliable + // since only the bot sends from that number. + // With shared number, bot messages carry the assistant name prefix + // (even in DMs/self-chat) so we check for that. + const isBotMessage = ASSISTANT_HAS_OWN_NUMBER + ? fromMe + : content.startsWith(`${ASSISTANT_NAME}:`); + + // Transcribe voice messages before storing + let finalContent = content; + if (isVoiceMessage(msg)) { + try { + const transcript = await transcribeAudioMessage(msg, this.sock); + if (transcript) { + finalContent = `[Voice: ${transcript}]`; + logger.info( + { chatJid, length: transcript.length }, + 'Transcribed voice message', + ); + } else { + finalContent = '[Voice Message - transcription unavailable]'; + } + } catch (err) { + logger.error({ err }, 'Voice transcription error'); + finalContent = '[Voice Message - transcription failed]'; + } + } + + this.opts.onMessage(chatJid, { + id: msg.key.id || '', + chat_jid: chatJid, + sender, + sender_name: senderName, + content: finalContent, + timestamp, + is_from_me: fromMe, + is_bot_message: isBotMessage, + }); + } + } + }); + } + + async sendMessage(jid: string, text: string): Promise { + // Prefix bot messages with assistant name so users know who's speaking. + // On a shared number, prefix is also needed in DMs (including self-chat) + // to distinguish bot output from user messages. + // Skip only when the assistant has its own dedicated phone number. + const prefixed = ASSISTANT_HAS_OWN_NUMBER + ? text + : `${ASSISTANT_NAME}: ${text}`; + + if (!this.connected) { + this.outgoingQueue.push({ jid, text: prefixed }); + logger.info( + { jid, length: prefixed.length, queueSize: this.outgoingQueue.length }, + 'WA disconnected, message queued', + ); + return; + } + try { + await this.sock.sendMessage(jid, { text: prefixed }); + logger.info({ jid, length: prefixed.length }, 'Message sent'); + } catch (err) { + // If send fails, queue it for retry on reconnect + this.outgoingQueue.push({ jid, text: prefixed }); + logger.warn( + { jid, err, queueSize: this.outgoingQueue.length }, + 'Failed to send, message queued', + ); + } + } + + isConnected(): boolean { + return this.connected; + } + + ownsJid(jid: string): boolean { + return jid.endsWith('@g.us') || jid.endsWith('@s.whatsapp.net'); + } + + async disconnect(): Promise { + this.connected = false; + this.sock?.end(undefined); + } + + async setTyping(jid: string, isTyping: boolean): Promise { + try { + const status = isTyping ? 'composing' : 'paused'; + logger.debug({ jid, status }, 'Sending presence update'); + await this.sock.sendPresenceUpdate(status, jid); + } catch (err) { + logger.debug({ jid, err }, 'Failed to update typing status'); + } + } + + /** + * Sync group metadata from WhatsApp. + * Fetches all participating groups and stores their names in the database. + * Called on startup, daily, and on-demand via IPC. + */ + async syncGroupMetadata(force = false): Promise { + if (!force) { + const lastSync = getLastGroupSync(); + if (lastSync) { + const lastSyncTime = new Date(lastSync).getTime(); + if (Date.now() - lastSyncTime < GROUP_SYNC_INTERVAL_MS) { + logger.debug({ lastSync }, 'Skipping group sync - synced recently'); + return; + } + } + } + + try { + logger.info('Syncing group metadata from WhatsApp...'); + const groups = await this.sock.groupFetchAllParticipating(); + + let count = 0; + for (const [jid, metadata] of Object.entries(groups)) { + if (metadata.subject) { + updateChatName(jid, metadata.subject); + count++; + } + } + + setLastGroupSync(); + logger.info({ count }, 'Group metadata synced'); + } catch (err) { + logger.error({ err }, 'Failed to sync group metadata'); + } + } + + private async translateJid(jid: string): Promise { + if (!jid.endsWith('@lid')) return jid; + const lidUser = jid.split('@')[0].split(':')[0]; + + // Check local cache first + const cached = this.lidToPhoneMap[lidUser]; + if (cached) { + logger.debug( + { lidJid: jid, phoneJid: cached }, + 'Translated LID to phone JID (cached)', + ); + return cached; + } + + // Query Baileys' signal repository for the mapping + try { + const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid); + if (pn) { + const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`; + this.lidToPhoneMap[lidUser] = phoneJid; + logger.info( + { lidJid: jid, phoneJid }, + 'Translated LID to phone JID (signalRepository)', + ); + return phoneJid; + } + } catch (err) { + logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository'); + } + + return jid; + } + + private async flushOutgoingQueue(): Promise { + if (this.flushing || this.outgoingQueue.length === 0) return; + this.flushing = true; + try { + logger.info( + { count: this.outgoingQueue.length }, + 'Flushing outgoing message queue', + ); + while (this.outgoingQueue.length > 0) { + const item = this.outgoingQueue.shift()!; + // Send directly — queued items are already prefixed by sendMessage + await this.sock.sendMessage(item.jid, { text: item.text }); + logger.info( + { jid: item.jid, length: item.text.length }, + 'Queued message sent', + ); + } + } finally { + this.flushing = false; + } + } +} diff --git a/src/config.ts b/src/config.ts index 43db54f..6ab3e85 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,12 +1,16 @@ -import os from 'os'; import path from 'path'; import { readEnvFile } from './env.js'; // Read config values from .env (falls back to process.env). -// Secrets (API keys, tokens) are NOT read here — they are loaded only -// by the credential proxy (credential-proxy.ts), never exposed to containers. -const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']); +// Secrets are NOT read here — they stay on disk and are loaded only +// where needed (container-runner.ts) to avoid leaking to child processes. +const envConfig = readEnvFile([ + 'ASSISTANT_NAME', + 'ASSISTANT_HAS_OWN_NUMBER', + 'TELEGRAM_BOT_TOKEN', + 'TELEGRAM_ONLY', +]); export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; @@ -18,7 +22,7 @@ export const SCHEDULER_POLL_INTERVAL = 60000; // Absolute paths needed for container mounts const PROJECT_ROOT = process.cwd(); -const HOME_DIR = process.env.HOME || os.homedir(); +const HOME_DIR = process.env.HOME || '/Users/user'; // Mount security: allowlist stored OUTSIDE project root, never mounted into containers export const MOUNT_ALLOWLIST_PATH = path.join( @@ -27,15 +31,10 @@ export const MOUNT_ALLOWLIST_PATH = path.join( 'nanoclaw', 'mount-allowlist.json', ); -export const SENDER_ALLOWLIST_PATH = path.join( - HOME_DIR, - '.config', - 'nanoclaw', - 'sender-allowlist.json', -); export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store'); export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); +export const MAIN_GROUP_FOLDER = 'main'; export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; @@ -47,10 +46,6 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt( process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10, ); // 10MB default -export const CREDENTIAL_PROXY_PORT = parseInt( - process.env.CREDENTIAL_PROXY_PORT || '3001', - 10, -); export const IPC_POLL_INTERVAL = 1000; export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result export const MAX_CONCURRENT_CONTAINERS = Math.max( @@ -71,3 +66,9 @@ export const TRIGGER_PATTERN = new RegExp( // Uses system timezone by default export const TIMEZONE = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone; + +// Telegram configuration +export const TELEGRAM_BOT_TOKEN = + process.env.TELEGRAM_BOT_TOKEN || envConfig.TELEGRAM_BOT_TOKEN || ''; +export const TELEGRAM_ONLY = + (process.env.TELEGRAM_ONLY || envConfig.TELEGRAM_ONLY) === 'true'; diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts index c830176..67af8e2 100644 --- a/src/container-runner.test.ts +++ b/src/container-runner.test.ts @@ -11,7 +11,6 @@ vi.mock('./config.js', () => ({ CONTAINER_IMAGE: 'nanoclaw-agent:latest', CONTAINER_MAX_OUTPUT_SIZE: 10485760, CONTAINER_TIMEOUT: 1800000, // 30min - CREDENTIAL_PROXY_PORT: 3001, DATA_DIR: '/tmp/nanoclaw-test-data', GROUPS_DIR: '/tmp/nanoclaw-test-groups', IDLE_TIMEOUT: 1800000, // 30min diff --git a/src/container-runner.ts b/src/container-runner.ts index be6f356..090a0bc 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -2,7 +2,7 @@ * Container Runner for NanoClaw * Spawns agent execution in containers and handles IPC */ -import { ChildProcess, exec, spawn } from 'child_process'; +import { ChildProcess, exec, execFileSync, spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; @@ -10,22 +10,20 @@ import { CONTAINER_IMAGE, CONTAINER_MAX_OUTPUT_SIZE, CONTAINER_TIMEOUT, - CREDENTIAL_PROXY_PORT, DATA_DIR, GROUPS_DIR, IDLE_TIMEOUT, TIMEZONE, } from './config.js'; +import { readEnvFile } from './env.js'; import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; import { logger } from './logger.js'; import { - CONTAINER_HOST_GATEWAY, CONTAINER_RUNTIME_BIN, - hostGatewayArgs, + isRootlessDocker, readonlyMountArgs, stopContainer, } from './container-runtime.js'; -import { detectAuthMode } from './credential-proxy.js'; import { validateAdditionalMounts } from './mount-security.js'; import { RegisteredGroup } from './types.js'; @@ -40,7 +38,9 @@ export interface ContainerInput { chatJid: string; isMain: boolean; isScheduledTask?: boolean; + ipcSuffix?: string; assistantName?: string; + secrets?: Record; } export interface ContainerOutput { @@ -59,6 +59,7 @@ interface VolumeMount { function buildVolumeMounts( group: RegisteredGroup, isMain: boolean, + ipcSuffix?: string, ): VolumeMount[] { const mounts: VolumeMount[] = []; const projectRoot = process.cwd(); @@ -76,17 +77,6 @@ function buildVolumeMounts( readonly: true, }); - // Shadow .env so the agent cannot read secrets from the mounted project root. - // Credentials are injected by the credential proxy, never exposed to containers. - const envFile = path.join(projectRoot, '.env'); - if (fs.existsSync(envFile)) { - mounts.push({ - hostPath: '/dev/null', - containerPath: '/workspace/project/.env', - readonly: true, - }); - } - // Main also gets its group folder as the working directory mounts.push({ hostPath: groupDir, @@ -163,9 +153,13 @@ function buildVolumeMounts( readonly: false, }); - // Per-group IPC namespace: each group gets its own IPC directory - // This prevents cross-group privilege escalation via IPC - const groupIpcDir = resolveGroupIpcPath(group.folder); + // Per-group IPC namespace: each group gets its own IPC directory. + // Task containers use a separate IPC dir (ipcSuffix='-task') to avoid + // cross-contamination with concurrent user message containers. + const ipcFolderName = ipcSuffix + ? `${group.folder}${ipcSuffix}` + : group.folder; + const groupIpcDir = path.join(DATA_DIR, 'ipc', ipcFolderName); fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); @@ -212,6 +206,25 @@ function buildVolumeMounts( return mounts; } +/** + * Read allowed secrets from .env for passing to the container via stdin. + * Secrets are never written to disk or mounted as files. + */ +function readSecrets(): Record { + return readEnvFile([ + 'CLAUDE_CODE_OAUTH_TOKEN', + 'ANTHROPIC_API_KEY', + 'PLANE_API_KEY', + 'PLANE_BASE_URL', + 'PLANE_WORKSPACE', + 'EMAIL_ADDRESS', + 'EMAIL_IMAP_PASSWORD', + 'EMAIL_CALDAV_PASSWORD', + 'CALDAV_URL', + 'COOLIFY_API_KEY', + ]); +} + function buildContainerArgs( mounts: VolumeMount[], containerName: string, @@ -221,26 +234,6 @@ function buildContainerArgs( // Pass host timezone so container's local time matches the user's args.push('-e', `TZ=${TIMEZONE}`); - // Route API traffic through the credential proxy (containers never see real secrets) - args.push( - '-e', - `ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`, - ); - - // Mirror the host's auth method with a placeholder value. - // API key mode: SDK sends x-api-key, proxy replaces with real key. - // OAuth mode: SDK exchanges placeholder token for temp API key, - // proxy injects real OAuth token on that exchange request. - const authMode = detectAuthMode(); - if (authMode === 'api-key') { - args.push('-e', 'ANTHROPIC_API_KEY=placeholder'); - } else { - args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder'); - } - - // Runtime-specific args for host gateway resolution - args.push(...hostGatewayArgs()); - // Run as host user so bind-mounted files are accessible. // Skip when running as root (uid 0), as the container's node user (uid 1000), // or when getuid is unavailable (native Windows without WSL). @@ -275,7 +268,25 @@ export async function runContainerAgent( const groupDir = resolveGroupFolderPath(group.folder); fs.mkdirSync(groupDir, { recursive: true }); - const mounts = buildVolumeMounts(group, input.isMain); + const mounts = buildVolumeMounts(group, input.isMain, input.ipcSuffix); + + // In rootless Docker, the container's node user (uid 1000) maps to a + // subordinate uid that can't write to host-owned directories. + // Make writable mounts world-accessible so the container user can write. + if (isRootlessDocker()) { + for (const mount of mounts) { + if (!mount.readonly) { + try { + execFileSync('chmod', ['-R', 'a+rwX', mount.hostPath], { + stdio: 'pipe', + }); + } catch { + /* best effort */ + } + } + } + } + const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); const containerName = `nanoclaw-${safeName}-${Date.now()}`; const containerArgs = buildContainerArgs(mounts, containerName); @@ -318,8 +329,12 @@ export async function runContainerAgent( let stdoutTruncated = false; let stderrTruncated = false; + // Pass secrets via stdin (never written to disk or mounted as files) + input.secrets = readSecrets(); container.stdin.write(JSON.stringify(input)); container.stdin.end(); + // Remove secrets from input so they don't appear in logs + delete input.secrets; // Streaming output: parse OUTPUT_START/END marker pairs as they arrive let parseBuffer = ''; @@ -369,10 +384,18 @@ export async function runContainerAgent( // so idle timers start even for "silent" query completions. outputChain = outputChain.then(() => onOutput(parsed)); } catch (err) { - logger.warn( - { group: group.name, error: err }, - 'Failed to parse streamed output chunk', - ); + parseErrorCount++; + if (parseErrorCount <= 3) { + logger.warn( + { group: group.name, error: err }, + 'Failed to parse streamed output chunk', + ); + } else if (parseErrorCount === 4) { + logger.warn( + { group: group.name }, + 'Suppressing further parse error warnings for this container run', + ); + } } } } @@ -402,6 +425,7 @@ export async function runContainerAgent( let timedOut = false; let hadStreamingOutput = false; + let parseErrorCount = 0; const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT; // Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the // graceful _close sentinel has time to trigger before the hard kill fires. diff --git a/src/container-runtime.ts b/src/container-runtime.ts index c4acdba..d517726 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -2,51 +2,33 @@ * Container runtime abstraction for NanoClaw. * All runtime-specific logic lives here so swapping runtimes means changing one file. */ -import { execSync } from 'child_process'; -import fs from 'fs'; -import os from 'os'; +import { execFileSync, execSync } from 'child_process'; import { logger } from './logger.js'; /** The container runtime binary name. */ export const CONTAINER_RUNTIME_BIN = 'docker'; -/** Hostname containers use to reach the host machine. */ -export const CONTAINER_HOST_GATEWAY = 'host.docker.internal'; - -/** - * Address the credential proxy binds to. - * Docker Desktop (macOS): 127.0.0.1 — the VM routes host.docker.internal to loopback. - * Docker (Linux): bind to the docker0 bridge IP so only containers can reach it, - * falling back to 0.0.0.0 if the interface isn't found. - */ -export const PROXY_BIND_HOST = - process.env.CREDENTIAL_PROXY_HOST || detectProxyBindHost(); - -function detectProxyBindHost(): string { - if (os.platform() === 'darwin') return '127.0.0.1'; - - // WSL uses Docker Desktop (same VM routing as macOS) — loopback is correct. - // Check /proc filesystem, not env vars — WSL_DISTRO_NAME isn't set under systemd. - if (fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')) return '127.0.0.1'; - - // Bare-metal Linux: bind to the docker0 bridge IP instead of 0.0.0.0 - const ifaces = os.networkInterfaces(); - const docker0 = ifaces['docker0']; - if (docker0) { - const ipv4 = docker0.find((a) => a.family === 'IPv4'); - if (ipv4) return ipv4.address; +/** Detect rootless Docker (container root maps to host user). */ +let _rootlessDocker: boolean | undefined; +export function isRootlessDocker(): boolean { + if (_rootlessDocker === undefined) { + try { + const info = execFileSync( + CONTAINER_RUNTIME_BIN, + ['info', '--format', '{{.SecurityOptions}}'], + { + stdio: 'pipe', + encoding: 'utf-8', + timeout: 5000, + }, + ); + _rootlessDocker = info.includes('rootless'); + } catch { + _rootlessDocker = false; + } } - return '0.0.0.0'; -} - -/** CLI args needed for the container to resolve the host gateway. */ -export function hostGatewayArgs(): string[] { - // On Linux, host.docker.internal isn't built-in — add it explicitly - if (os.platform() === 'linux') { - return ['--add-host=host.docker.internal:host-gateway']; - } - return []; + return _rootlessDocker; } /** Returns CLI args for a readonly bind mount. */ diff --git a/src/db.test.ts b/src/db.test.ts index a40d376..e00238e 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -2,14 +2,14 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, + closeDatabase, createTask, deleteTask, getAllChats, - getAllRegisteredGroups, getMessagesSince, getNewMessages, getTaskById, - setRegisteredGroup, + logTaskRun, storeChatMetadata, storeMessage, updateTask, @@ -391,94 +391,37 @@ describe('task CRUD', () => { }); }); -// --- LIMIT behavior --- - -describe('message query LIMIT', () => { - beforeEach(() => { - storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); - - for (let i = 1; i <= 10; i++) { - store({ - id: `lim-${i}`, - chat_jid: 'group@g.us', - sender: 'user@s.whatsapp.net', - sender_name: 'User', - content: `message ${i}`, - timestamp: `2024-01-01T00:00:${String(i).padStart(2, '0')}.000Z`, - }); - } - }); - - it('getNewMessages caps to limit and returns most recent in chronological order', () => { - const { messages, newTimestamp } = getNewMessages( - ['group@g.us'], - '2024-01-01T00:00:00.000Z', - 'Andy', - 3, - ); - expect(messages).toHaveLength(3); - expect(messages[0].content).toBe('message 8'); - expect(messages[2].content).toBe('message 10'); - // Chronological order preserved - expect(messages[1].timestamp > messages[0].timestamp).toBe(true); - // newTimestamp reflects latest returned row - expect(newTimestamp).toBe('2024-01-01T00:00:10.000Z'); - }); - - it('getMessagesSince caps to limit and returns most recent in chronological order', () => { - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - 3, - ); - expect(messages).toHaveLength(3); - expect(messages[0].content).toBe('message 8'); - expect(messages[2].content).toBe('message 10'); - expect(messages[1].timestamp > messages[0].timestamp).toBe(true); - }); - - it('returns all messages when count is under the limit', () => { - const { messages } = getNewMessages( - ['group@g.us'], - '2024-01-01T00:00:00.000Z', - 'Andy', - 50, - ); - expect(messages).toHaveLength(10); +describe('closeDatabase', () => { + it('can be called without throwing', () => { + expect(() => closeDatabase()).not.toThrow(); }); }); -// --- RegisteredGroup isMain round-trip --- - -describe('registered group isMain', () => { - it('persists isMain=true through set/get round-trip', () => { - setRegisteredGroup('main@s.whatsapp.net', { - name: 'Main Chat', - folder: 'whatsapp_main', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - isMain: true, +describe('deleteTask atomicity', () => { + it('deletes task and its logs', () => { + createTask({ + id: 'task-del-1', + group_folder: 'main', + chat_jid: 'jid@g.us', + prompt: 'test', + schedule_type: 'once', + schedule_value: '2026-01-01T00:00:00Z', + context_mode: 'isolated', + next_run: '2026-01-01T00:00:00Z', + status: 'active', + created_at: new Date().toISOString(), + }); + logTaskRun({ + task_id: 'task-del-1', + run_at: new Date().toISOString(), + duration_ms: 100, + status: 'success', + result: 'ok', + error: null, }); - const groups = getAllRegisteredGroups(); - const group = groups['main@s.whatsapp.net']; - expect(group).toBeDefined(); - expect(group.isMain).toBe(true); - expect(group.folder).toBe('whatsapp_main'); - }); + deleteTask('task-del-1'); - it('omits isMain for non-main groups', () => { - setRegisteredGroup('group@g.us', { - name: 'Family Chat', - folder: 'whatsapp_family-chat', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }); - - const groups = getAllRegisteredGroups(); - const group = groups['group@g.us']; - expect(group).toBeDefined(); - expect(group.isMain).toBeUndefined(); + expect(getTaskById('task-del-1')).toBeUndefined(); }); }); diff --git a/src/db.ts b/src/db.ts index 0896f41..a31115a 100644 --- a/src/db.ts +++ b/src/db.ts @@ -106,19 +106,6 @@ function createSchema(database: Database.Database): void { /* column already exists */ } - // Add is_main column if it doesn't exist (migration for existing DBs) - try { - database.exec( - `ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`, - ); - // Backfill: existing rows with folder = 'main' are the main group - database.exec( - `UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`, - ); - } catch { - /* column already exists */ - } - // Add channel and is_group columns if they don't exist (migration for existing DBs) try { database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`); @@ -158,6 +145,10 @@ export function _initTestDatabase(): void { createSchema(db); } +export function closeDatabase(): void { + db?.close(); +} + /** * Store chat metadata only (no message content). * Used for all chats to enable group discovery without storing sensitive content. @@ -276,7 +267,7 @@ export function storeMessage(msg: NewMessage): void { } /** - * Store a message directly. + * Store a message directly (for non-WhatsApp channels that don't use Baileys proto). */ export function storeMessageDirect(msg: { id: string; @@ -306,29 +297,24 @@ export function getNewMessages( jids: string[], lastTimestamp: string, botPrefix: string, - limit: number = 200, ): { messages: NewMessage[]; newTimestamp: string } { if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp }; const placeholders = jids.map(() => '?').join(','); // Filter bot messages using both the is_bot_message flag AND the content // prefix as a backstop for messages written before the migration ran. - // Subquery takes the N most recent, outer query re-sorts chronologically. const sql = ` - SELECT * FROM ( - SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me - FROM messages - WHERE timestamp > ? AND chat_jid IN (${placeholders}) - AND is_bot_message = 0 AND content NOT LIKE ? - AND content != '' AND content IS NOT NULL - ORDER BY timestamp DESC - LIMIT ? - ) ORDER BY timestamp + SELECT id, chat_jid, sender, sender_name, content, timestamp + FROM messages + WHERE timestamp > ? AND chat_jid IN (${placeholders}) + AND is_bot_message = 0 AND content NOT LIKE ? + AND content != '' AND content IS NOT NULL + ORDER BY timestamp `; const rows = db .prepare(sql) - .all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[]; + .all(lastTimestamp, ...jids, `${botPrefix}:%`) as NewMessage[]; let newTimestamp = lastTimestamp; for (const row of rows) { @@ -342,25 +328,20 @@ export function getMessagesSince( chatJid: string, sinceTimestamp: string, botPrefix: string, - limit: number = 200, ): NewMessage[] { // Filter bot messages using both the is_bot_message flag AND the content // prefix as a backstop for messages written before the migration ran. - // Subquery takes the N most recent, outer query re-sorts chronologically. const sql = ` - SELECT * FROM ( - SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me - FROM messages - WHERE chat_jid = ? AND timestamp > ? - AND is_bot_message = 0 AND content NOT LIKE ? - AND content != '' AND content IS NOT NULL - ORDER BY timestamp DESC - LIMIT ? - ) ORDER BY timestamp + SELECT id, chat_jid, sender, sender_name, content, timestamp + FROM messages + WHERE chat_jid = ? AND timestamp > ? + AND is_bot_message = 0 AND content NOT LIKE ? + AND content != '' AND content IS NOT NULL + ORDER BY timestamp `; return db .prepare(sql) - .all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[]; + .all(chatJid, sinceTimestamp, `${botPrefix}:%`) as NewMessage[]; } export function createTask( @@ -447,9 +428,10 @@ export function updateTask( } export function deleteTask(id: string): void { - // Delete child records first (FK constraint) - db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id); - db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id); + db.transaction(() => { + db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id); + db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id); + })(); } export function getDueTasks(): ScheduledTask[] { @@ -526,6 +508,10 @@ export function setSession(groupFolder: string, sessionId: string): void { ).run(groupFolder, sessionId); } +export function deleteSession(groupFolder: string): void { + db.prepare('DELETE FROM sessions WHERE group_folder = ?').run(groupFolder); +} + export function getAllSessions(): Record { const rows = db .prepare('SELECT group_folder, session_id FROM sessions') @@ -553,7 +539,6 @@ export function getRegisteredGroup( added_at: string; container_config: string | null; requires_trigger: number | null; - is_main: number | null; } | undefined; if (!row) return undefined; @@ -575,7 +560,6 @@ export function getRegisteredGroup( : undefined, requiresTrigger: row.requires_trigger === null ? undefined : row.requires_trigger === 1, - isMain: row.is_main === 1 ? true : undefined, }; } @@ -584,8 +568,8 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`); } db.prepare( - `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) + VALUES (?, ?, ?, ?, ?, ?, ?)`, ).run( jid, group.name, @@ -594,7 +578,6 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { group.added_at, group.containerConfig ? JSON.stringify(group.containerConfig) : null, group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0, - group.isMain ? 1 : 0, ); } @@ -607,7 +590,6 @@ export function getAllRegisteredGroups(): Record { added_at: string; container_config: string | null; requires_trigger: number | null; - is_main: number | null; }>; const result: Record = {}; for (const row of rows) { @@ -628,7 +610,6 @@ export function getAllRegisteredGroups(): Record { : undefined, requiresTrigger: row.requires_trigger === null ? undefined : row.requires_trigger === 1, - isMain: row.is_main === 1 ? true : undefined, }; } return result; diff --git a/src/formatting.test.ts b/src/formatting.test.ts index 8a2160c..ea85b9d 100644 --- a/src/formatting.test.ts +++ b/src/formatting.test.ts @@ -58,14 +58,13 @@ describe('escapeXml', () => { // --- formatMessages --- describe('formatMessages', () => { - const TZ = 'UTC'; - - it('formats a single message as XML with context header', () => { - const result = formatMessages([makeMsg()], TZ); - expect(result).toContain(''); - expect(result).toContain('hello'); - expect(result).toContain('Jan 1, 2024'); + it('formats a single message as XML', () => { + const result = formatMessages([makeMsg()]); + expect(result).toBe( + '\n' + + 'hello\n' + + '', + ); }); it('formats multiple messages', () => { @@ -74,16 +73,11 @@ describe('formatMessages', () => { id: '1', sender_name: 'Alice', content: 'hi', - timestamp: '2024-01-01T00:00:00.000Z', - }), - makeMsg({ - id: '2', - sender_name: 'Bob', - content: 'hey', - timestamp: '2024-01-01T01:00:00.000Z', + timestamp: 't1', }), + makeMsg({ id: '2', sender_name: 'Bob', content: 'hey', timestamp: 't2' }), ]; - const result = formatMessages(msgs, TZ); + const result = formatMessages(msgs); expect(result).toContain('sender="Alice"'); expect(result).toContain('sender="Bob"'); expect(result).toContain('>hi'); @@ -91,35 +85,22 @@ describe('formatMessages', () => { }); it('escapes special characters in sender names', () => { - const result = formatMessages([makeMsg({ sender_name: 'A & B ' })], TZ); + const result = formatMessages([makeMsg({ sender_name: 'A & B ' })]); expect(result).toContain('sender="A & B <Co>"'); }); it('escapes special characters in content', () => { - const result = formatMessages( - [makeMsg({ content: '' })], - TZ, - ); + const result = formatMessages([ + makeMsg({ content: '' }), + ]); expect(result).toContain( '<script>alert("xss")</script>', ); }); it('handles empty array', () => { - const result = formatMessages([], TZ); - expect(result).toContain(''); - expect(result).toContain('\n\n'); - }); - - it('converts timestamps to local time for given timezone', () => { - // 2024-01-01T18:30:00Z in America/New_York (EST) = 1:30 PM - const result = formatMessages( - [makeMsg({ timestamp: '2024-01-01T18:30:00.000Z' })], - 'America/New_York', - ); - expect(result).toContain('1:30'); - expect(result).toContain('PM'); - expect(result).toContain(''); + const result = formatMessages([]); + expect(result).toBe('\n\n'); }); }); diff --git a/src/group-queue.test.ts b/src/group-queue.test.ts index ca2702a..b1a4f9c 100644 --- a/src/group-queue.test.ts +++ b/src/group-queue.test.ts @@ -243,41 +243,6 @@ describe('GroupQueue', () => { expect(processed).toContain('group3@g.us'); }); - // --- Running task dedup (Issue #138) --- - - it('rejects duplicate enqueue of a currently-running task', async () => { - let resolveTask: () => void; - let taskCallCount = 0; - - const taskFn = vi.fn(async () => { - taskCallCount++; - await new Promise((resolve) => { - resolveTask = resolve; - }); - }); - - // Start the task (runs immediately — slot available) - queue.enqueueTask('group1@g.us', 'task-1', taskFn); - await vi.advanceTimersByTimeAsync(10); - expect(taskCallCount).toBe(1); - - // Scheduler poll re-discovers the same task while it's running — - // this must be silently dropped - const dupFn = vi.fn(async () => {}); - queue.enqueueTask('group1@g.us', 'task-1', dupFn); - await vi.advanceTimersByTimeAsync(10); - - // Duplicate was NOT queued - expect(dupFn).not.toHaveBeenCalled(); - - // Complete the original task - resolveTask!(); - await vi.advanceTimersByTimeAsync(10); - - // Only one execution total - expect(taskCallCount).toBe(1); - }); - // --- Idle preemption --- it('does NOT preempt active container when not idle', async () => { diff --git a/src/group-queue.ts b/src/group-queue.ts index f2984ce..5ebf663 100644 --- a/src/group-queue.ts +++ b/src/group-queue.ts @@ -18,12 +18,13 @@ interface GroupState { active: boolean; idleWaiting: boolean; isTaskContainer: boolean; - runningTaskId: string | null; pendingMessages: boolean; pendingTasks: QueuedTask[]; process: ChildProcess | null; containerName: string | null; groupFolder: string | null; + /** IPC dir name — may differ from groupFolder for task containers (e.g. 'main-task'). */ + ipcFolder: string | null; retryCount: number; } @@ -42,12 +43,12 @@ export class GroupQueue { active: false, idleWaiting: false, isTaskContainer: false, - runningTaskId: null, pendingMessages: false, pendingTasks: [], process: null, containerName: null, groupFolder: null, + ipcFolder: null, retryCount: 0, }; this.groups.set(groupJid, state); @@ -92,11 +93,7 @@ export class GroupQueue { const state = this.getGroup(groupJid); - // Prevent double-queuing: check both pending and currently-running task - if (state.runningTaskId === taskId) { - logger.debug({ groupJid, taskId }, 'Task already running, skipping'); - return; - } + // Prevent double-queuing of the same task if (state.pendingTasks.some((t) => t.id === taskId)) { logger.debug({ groupJid, taskId }, 'Task already queued, skipping'); return; @@ -134,11 +131,13 @@ export class GroupQueue { proc: ChildProcess, containerName: string, groupFolder?: string, + ipcFolder?: string, ): void { const state = this.getGroup(groupJid); state.process = proc; state.containerName = containerName; if (groupFolder) state.groupFolder = groupFolder; + state.ipcFolder = ipcFolder ?? groupFolder ?? null; } /** @@ -159,11 +158,11 @@ export class GroupQueue { */ sendMessage(groupJid: string, text: string): boolean { const state = this.getGroup(groupJid); - if (!state.active || !state.groupFolder || state.isTaskContainer) + if (!state.active || !state.ipcFolder || state.isTaskContainer) return false; state.idleWaiting = false; // Agent is about to receive work, no longer idle - const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input'); + const inputDir = path.join(DATA_DIR, 'ipc', state.ipcFolder, 'input'); try { fs.mkdirSync(inputDir, { recursive: true }); const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}.json`; @@ -182,9 +181,9 @@ export class GroupQueue { */ closeStdin(groupJid: string): void { const state = this.getGroup(groupJid); - if (!state.active || !state.groupFolder) return; + if (!state.active || !state.ipcFolder) return; - const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input'); + const inputDir = path.join(DATA_DIR, 'ipc', state.ipcFolder, 'input'); try { fs.mkdirSync(inputDir, { recursive: true }); fs.writeFileSync(path.join(inputDir, '_close'), ''); @@ -226,6 +225,7 @@ export class GroupQueue { state.process = null; state.containerName = null; state.groupFolder = null; + state.ipcFolder = null; this.activeCount--; this.drainGroup(groupJid); } @@ -236,7 +236,6 @@ export class GroupQueue { state.active = true; state.idleWaiting = false; state.isTaskContainer = true; - state.runningTaskId = task.id; this.activeCount++; logger.debug( @@ -251,10 +250,10 @@ export class GroupQueue { } finally { state.active = false; state.isTaskContainer = false; - state.runningTaskId = null; state.process = null; state.containerName = null; state.groupFolder = null; + state.ipcFolder = null; this.activeCount--; this.drainGroup(groupJid); } diff --git a/src/index.ts b/src/index.ts index c6295c5..d41e214 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,18 +3,16 @@ import path from 'path'; import { ASSISTANT_NAME, - CREDENTIAL_PROXY_PORT, + DATA_DIR, IDLE_TIMEOUT, + MAIN_GROUP_FOLDER, POLL_INTERVAL, - TIMEZONE, + TELEGRAM_BOT_TOKEN, + TELEGRAM_ONLY, TRIGGER_PATTERN, } from './config.js'; -import { startCredentialProxy } from './credential-proxy.js'; -import './channels/index.js'; -import { - getChannelFactory, - getRegisteredChannelNames, -} from './channels/registry.js'; +import { WhatsAppChannel } from './channels/whatsapp.js'; +import { TelegramChannel } from './channels/telegram.js'; import { ContainerOutput, runContainerAgent, @@ -22,18 +20,14 @@ import { writeTasksSnapshot, } from './container-runner.js'; import { - cleanupOrphans, - ensureContainerRuntimeRunning, - PROXY_BIND_HOST, -} from './container-runtime.js'; -import { + closeDatabase, + deleteSession, getAllChats, getAllRegisteredGroups, getAllSessions, getAllTasks, getMessagesSince, getNewMessages, - getRegisteredGroup, getRouterState, initDatabase, setRegisteredGroup, @@ -43,18 +37,16 @@ import { storeMessage, } from './db.js'; import { GroupQueue } from './group-queue.js'; -import { resolveGroupFolderPath } from './group-folder.js'; import { startIpcWatcher } from './ipc.js'; import { findChannel, formatMessages, formatOutbound } from './router.js'; -import { - isSenderAllowed, - isTriggerAllowed, - loadSenderAllowlist, - shouldDropMessage, -} from './sender-allowlist.js'; import { startSchedulerLoop } from './task-scheduler.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; +import { + ensureContainerRuntimeRunning, + cleanupOrphans, +} from './container-runtime.js'; +import { resolveGroupFolderPath } from './group-folder.js'; // Re-export for backwards compatibility during refactor export { escapeXml, formatMessages } from './router.js'; @@ -65,6 +57,7 @@ let registeredGroups: Record = {}; let lastAgentTimestamp: Record = {}; let messageLoopRunning = false; +let whatsapp: WhatsAppChannel; const channels: Channel[] = []; const queue = new GroupQueue(); @@ -91,22 +84,24 @@ function saveState(): void { } function registerGroup(jid: string, group: RegisteredGroup): void { - let groupDir: string; - try { - groupDir = resolveGroupFolderPath(group.folder); - } catch (err) { - logger.warn( - { jid, folder: group.folder, err }, - 'Rejecting group registration with invalid folder', - ); - return; - } - - registeredGroups[jid] = group; + // Validate + persist first (setRegisteredGroup throws on invalid folder) setRegisteredGroup(jid, group); - // Create group folder - fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); + // Create group folder after DB write so a mkdir failure doesn't leave + // a DB record with no matching directory + const groupDir = resolveGroupFolderPath(group.folder); + try { + fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); + } catch (err) { + logger.error( + { jid, folder: group.folder, err }, + 'Failed to create group directory', + ); + // Don't throw — DB record is written; directory can be created on next restart + } + + // Update in-memory state after I/O succeeds + registeredGroups[jid] = group; logger.info( { jid, name: group.name, folder: group.folder }, @@ -153,7 +148,7 @@ async function processGroupMessages(chatJid: string): Promise { return true; } - const isMainGroup = group.isMain === true; + const isMainGroup = group.folder === MAIN_GROUP_FOLDER; const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; const missedMessages = getMessagesSince( @@ -166,16 +161,13 @@ async function processGroupMessages(chatJid: string): Promise { // For non-main groups, check if trigger is required and present if (!isMainGroup && group.requiresTrigger !== false) { - const allowlistCfg = loadSenderAllowlist(); - const hasTrigger = missedMessages.some( - (m) => - TRIGGER_PATTERN.test(m.content.trim()) && - (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), + const hasTrigger = missedMessages.some((m) => + TRIGGER_PATTERN.test(m.content.trim()), ); if (!hasTrigger) return true; } - const prompt = formatMessages(missedMessages, TIMEZONE); + const prompt = formatMessages(missedMessages); // Advance cursor so the piping path in startMessageLoop won't re-fetch // these messages. Save the old cursor so we can roll back on error. @@ -225,10 +217,6 @@ async function processGroupMessages(chatJid: string): Promise { resetIdleTimer(); } - if (result.status === 'success') { - queue.notifyIdle(chatJid); - } - if (result.status === 'error') { hadError = true; } @@ -266,7 +254,7 @@ async function runAgent( chatJid: string, onOutput?: (output: ContainerOutput) => Promise, ): Promise<'success' | 'error'> { - const isMain = group.isMain === true; + const isMain = group.folder === MAIN_GROUP_FOLDER; const sessionId = sessions[group.folder]; // Update tasks snapshot for container to read (filtered by group) @@ -314,7 +302,6 @@ async function runAgent( groupFolder: group.folder, chatJid, isMain, - assistantName: ASSISTANT_NAME, }, (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder), @@ -350,6 +337,7 @@ async function startMessageLoop(): Promise { logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`); + let consecutiveErrors = 0; while (true) { try { const jids = Object.keys(registeredGroups); @@ -387,19 +375,15 @@ async function startMessageLoop(): Promise { continue; } - const isMainGroup = group.isMain === true; + const isMainGroup = group.folder === MAIN_GROUP_FOLDER; const needsTrigger = !isMainGroup && group.requiresTrigger !== false; // For non-main groups, only act on trigger messages. // Non-trigger messages accumulate in DB and get pulled as // context when a trigger eventually arrives. if (needsTrigger) { - const allowlistCfg = loadSenderAllowlist(); - const hasTrigger = groupMessages.some( - (m) => - TRIGGER_PATTERN.test(m.content.trim()) && - (m.is_from_me || - isTriggerAllowed(chatJid, m.sender, allowlistCfg)), + const hasTrigger = groupMessages.some((m) => + TRIGGER_PATTERN.test(m.content.trim()), ); if (!hasTrigger) continue; } @@ -413,7 +397,7 @@ async function startMessageLoop(): Promise { ); const messagesToSend = allPending.length > 0 ? allPending : groupMessages; - const formatted = formatMessages(messagesToSend, TIMEZONE); + const formatted = formatMessages(messagesToSend); if (queue.sendMessage(chatJid, formatted)) { logger.debug( @@ -435,8 +419,17 @@ async function startMessageLoop(): Promise { } } } + consecutiveErrors = 0; } catch (err) { - logger.error({ err }, 'Error in message loop'); + consecutiveErrors++; + if (consecutiveErrors === 1) { + logger.error({ err }, 'Error in message loop'); + } else if (consecutiveErrors % 10 === 0) { + logger.error( + { err, consecutiveErrors }, + 'Message loop error persisting — check DB and disk', + ); + } } await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); } @@ -460,29 +453,19 @@ function recoverPendingMessages(): void { } } -function ensureContainerSystemRunning(): void { +async function main(): Promise { ensureContainerRuntimeRunning(); cleanupOrphans(); -} - -async function main(): Promise { - ensureContainerSystemRunning(); initDatabase(); logger.info('Database initialized'); loadState(); - // Start credential proxy (containers route API calls through this) - const proxyServer = await startCredentialProxy( - CREDENTIAL_PROXY_PORT, - PROXY_BIND_HOST, - ); - // Graceful shutdown handlers const shutdown = async (signal: string) => { logger.info({ signal }, 'Shutdown signal received'); - proxyServer.close(); await queue.shutdown(10000); for (const ch of channels) await ch.disconnect(); + closeDatabase(); process.exit(0); }; process.on('SIGTERM', () => shutdown('SIGTERM')); @@ -490,25 +473,7 @@ async function main(): Promise { // Channel callbacks (shared by all channels) const channelOpts = { - onMessage: (chatJid: string, msg: NewMessage) => { - // Sender allowlist drop mode: discard messages from denied senders before storing - if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) { - const cfg = loadSenderAllowlist(); - if ( - shouldDropMessage(chatJid, cfg) && - !isSenderAllowed(chatJid, msg.sender, cfg) - ) { - if (cfg.logDenied) { - logger.debug( - { chatJid, sender: msg.sender }, - 'sender-allowlist: dropping message (drop mode)', - ); - } - return; - } - } - storeMessage(msg); - }, + onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg), onChatMetadata: ( chatJid: string, timestamp: string, @@ -517,27 +482,30 @@ async function main(): Promise { isGroup?: boolean, ) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup), registeredGroups: () => registeredGroups, + clearSession: (chatJid: string) => { + const group = registeredGroups[chatJid]; + if (group) { + delete sessions[group.folder]; + deleteSession(group.folder); + logger.info( + { chatJid, folder: group.folder }, + 'Session cleared via /reset', + ); + } + }, }; - // Create and connect all registered channels. - // Each channel self-registers via the barrel import above. - // Factories return null when credentials are missing, so unconfigured channels are skipped. - for (const channelName of getRegisteredChannelNames()) { - const factory = getChannelFactory(channelName)!; - const channel = factory(channelOpts); - if (!channel) { - logger.warn( - { channel: channelName }, - 'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.', - ); - continue; - } - channels.push(channel); - await channel.connect(); + // Create and connect channels + if (!TELEGRAM_ONLY) { + whatsapp = new WhatsAppChannel(channelOpts); + channels.push(whatsapp); + await whatsapp.connect(); } - if (channels.length === 0) { - logger.fatal('No channels connected'); - process.exit(1); + + if (TELEGRAM_BOT_TOKEN) { + const telegram = new TelegramChannel(TELEGRAM_BOT_TOKEN, channelOpts); + channels.push(telegram); + await telegram.connect(); } // Start subsystems (independently of connection handler) @@ -545,8 +513,14 @@ async function main(): Promise { registeredGroups: () => registeredGroups, getSessions: () => sessions, queue, - onProcess: (groupJid, proc, containerName, groupFolder) => - queue.registerProcess(groupJid, proc, containerName, groupFolder), + onProcess: (queueKey, proc, containerName, groupFolder, ipcFolder) => + queue.registerProcess( + queueKey, + proc, + containerName, + groupFolder, + ipcFolder, + ), sendMessage: async (jid, rawText) => { const channel = findChannel(channels, jid); if (!channel) { @@ -565,23 +539,15 @@ async function main(): Promise { }, registeredGroups: () => registeredGroups, registerGroup, - syncGroups: async (force: boolean) => { - await Promise.all( - channels - .filter((ch) => ch.syncGroups) - .map((ch) => ch.syncGroups!(force)), - ); - }, + syncGroupMetadata: (force) => + whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(), getAvailableGroups, writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj), }); queue.setProcessMessagesFn(processGroupMessages); recoverPendingMessages(); - startMessageLoop().catch((err) => { - logger.fatal({ err }, 'Message loop crashed unexpectedly'); - process.exit(1); - }); + startMessageLoop(); } // Guard: only run when executed directly, not when imported by tests diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index 1aa681e..e155d44 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -14,10 +14,9 @@ import { RegisteredGroup } from './types.js'; // Set up registered groups used across tests const MAIN_GROUP: RegisteredGroup = { name: 'Main', - folder: 'whatsapp_main', + folder: 'main', trigger: 'always', added_at: '2024-01-01T00:00:00.000Z', - isMain: true, }; const OTHER_GROUP: RegisteredGroup = { @@ -59,7 +58,7 @@ beforeEach(() => { setRegisteredGroup(jid, group); // Mock the fs.mkdirSync that registerGroup does }, - syncGroups: async () => {}, + syncGroupMetadata: async () => {}, getAvailableGroups: () => [], writeGroupsSnapshot: () => {}, }; @@ -74,10 +73,10 @@ describe('schedule_task authorization', () => { type: 'schedule_task', prompt: 'do something', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00', + schedule_value: '2025-06-01T00:00:00.000Z', targetJid: 'other@g.us', }, - 'whatsapp_main', + 'main', true, deps, ); @@ -94,7 +93,7 @@ describe('schedule_task authorization', () => { type: 'schedule_task', prompt: 'self task', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00', + schedule_value: '2025-06-01T00:00:00.000Z', targetJid: 'other@g.us', }, 'other-group', @@ -113,7 +112,7 @@ describe('schedule_task authorization', () => { type: 'schedule_task', prompt: 'unauthorized', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00', + schedule_value: '2025-06-01T00:00:00.000Z', targetJid: 'main@g.us', }, 'other-group', @@ -131,10 +130,10 @@ describe('schedule_task authorization', () => { type: 'schedule_task', prompt: 'no target', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00', + schedule_value: '2025-06-01T00:00:00.000Z', targetJid: 'unknown@g.us', }, - 'whatsapp_main', + 'main', true, deps, ); @@ -150,11 +149,11 @@ describe('pause_task authorization', () => { beforeEach(() => { createTask({ id: 'task-main', - group_folder: 'whatsapp_main', + group_folder: 'main', chat_jid: 'main@g.us', prompt: 'main task', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00', + schedule_value: '2025-06-01T00:00:00.000Z', context_mode: 'isolated', next_run: '2025-06-01T00:00:00.000Z', status: 'active', @@ -166,7 +165,7 @@ describe('pause_task authorization', () => { chat_jid: 'other@g.us', prompt: 'other task', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00', + schedule_value: '2025-06-01T00:00:00.000Z', context_mode: 'isolated', next_run: '2025-06-01T00:00:00.000Z', status: 'active', @@ -177,7 +176,7 @@ describe('pause_task authorization', () => { it('main group can pause any task', async () => { await processTaskIpc( { type: 'pause_task', taskId: 'task-other' }, - 'whatsapp_main', + 'main', true, deps, ); @@ -215,7 +214,7 @@ describe('resume_task authorization', () => { chat_jid: 'other@g.us', prompt: 'paused task', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00', + schedule_value: '2025-06-01T00:00:00.000Z', context_mode: 'isolated', next_run: '2025-06-01T00:00:00.000Z', status: 'paused', @@ -226,7 +225,7 @@ describe('resume_task authorization', () => { it('main group can resume any task', async () => { await processTaskIpc( { type: 'resume_task', taskId: 'task-paused' }, - 'whatsapp_main', + 'main', true, deps, ); @@ -264,7 +263,7 @@ describe('cancel_task authorization', () => { chat_jid: 'other@g.us', prompt: 'cancel me', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00', + schedule_value: '2025-06-01T00:00:00.000Z', context_mode: 'isolated', next_run: null, status: 'active', @@ -273,7 +272,7 @@ describe('cancel_task authorization', () => { await processTaskIpc( { type: 'cancel_task', taskId: 'task-to-cancel' }, - 'whatsapp_main', + 'main', true, deps, ); @@ -287,7 +286,7 @@ describe('cancel_task authorization', () => { chat_jid: 'other@g.us', prompt: 'my task', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00', + schedule_value: '2025-06-01T00:00:00.000Z', context_mode: 'isolated', next_run: null, status: 'active', @@ -306,11 +305,11 @@ describe('cancel_task authorization', () => { it('non-main group cannot cancel another groups task', async () => { createTask({ id: 'task-foreign', - group_folder: 'whatsapp_main', + group_folder: 'main', chat_jid: 'main@g.us', prompt: 'not yours', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00', + schedule_value: '2025-06-01T00:00:00.000Z', context_mode: 'isolated', next_run: null, status: 'active', @@ -357,7 +356,7 @@ describe('register_group authorization', () => { folder: '../../outside', trigger: '@Andy', }, - 'whatsapp_main', + 'main', true, deps, ); @@ -398,12 +397,8 @@ describe('IPC message authorization', () => { } it('main group can send to any group', () => { - expect( - isMessageAuthorized('whatsapp_main', true, 'other@g.us', groups), - ).toBe(true); - expect( - isMessageAuthorized('whatsapp_main', true, 'third@g.us', groups), - ).toBe(true); + expect(isMessageAuthorized('main', true, 'other@g.us', groups)).toBe(true); + expect(isMessageAuthorized('main', true, 'third@g.us', groups)).toBe(true); }); it('non-main group can send to its own chat', () => { @@ -429,9 +424,9 @@ describe('IPC message authorization', () => { it('main group can send to unregistered JID', () => { // Main is always authorized regardless of target - expect( - isMessageAuthorized('whatsapp_main', true, 'unknown@g.us', groups), - ).toBe(true); + expect(isMessageAuthorized('main', true, 'unknown@g.us', groups)).toBe( + true, + ); }); }); @@ -447,7 +442,7 @@ describe('schedule_task schedule types', () => { schedule_value: '0 9 * * *', // every day at 9am targetJid: 'other@g.us', }, - 'whatsapp_main', + 'main', true, deps, ); @@ -471,7 +466,7 @@ describe('schedule_task schedule types', () => { schedule_value: 'not a cron', targetJid: 'other@g.us', }, - 'whatsapp_main', + 'main', true, deps, ); @@ -490,7 +485,7 @@ describe('schedule_task schedule types', () => { schedule_value: '3600000', // 1 hour targetJid: 'other@g.us', }, - 'whatsapp_main', + 'main', true, deps, ); @@ -513,7 +508,7 @@ describe('schedule_task schedule types', () => { schedule_value: 'abc', targetJid: 'other@g.us', }, - 'whatsapp_main', + 'main', true, deps, ); @@ -530,7 +525,7 @@ describe('schedule_task schedule types', () => { schedule_value: '0', targetJid: 'other@g.us', }, - 'whatsapp_main', + 'main', true, deps, ); @@ -547,7 +542,7 @@ describe('schedule_task schedule types', () => { schedule_value: 'not-a-date', targetJid: 'other@g.us', }, - 'whatsapp_main', + 'main', true, deps, ); @@ -565,11 +560,11 @@ describe('schedule_task context_mode', () => { type: 'schedule_task', prompt: 'group context', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00', + schedule_value: '2025-06-01T00:00:00.000Z', context_mode: 'group', targetJid: 'other@g.us', }, - 'whatsapp_main', + 'main', true, deps, ); @@ -584,11 +579,11 @@ describe('schedule_task context_mode', () => { type: 'schedule_task', prompt: 'isolated context', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00', + schedule_value: '2025-06-01T00:00:00.000Z', context_mode: 'isolated', targetJid: 'other@g.us', }, - 'whatsapp_main', + 'main', true, deps, ); @@ -603,11 +598,11 @@ describe('schedule_task context_mode', () => { type: 'schedule_task', prompt: 'bad context', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00', + schedule_value: '2025-06-01T00:00:00.000Z', context_mode: 'bogus' as any, targetJid: 'other@g.us', }, - 'whatsapp_main', + 'main', true, deps, ); @@ -622,10 +617,10 @@ describe('schedule_task context_mode', () => { type: 'schedule_task', prompt: 'no context mode', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00', + schedule_value: '2025-06-01T00:00:00.000Z', targetJid: 'other@g.us', }, - 'whatsapp_main', + 'main', true, deps, ); @@ -647,7 +642,7 @@ describe('register_group success', () => { folder: 'new-group', trigger: '@Andy', }, - 'whatsapp_main', + 'main', true, deps, ); @@ -668,7 +663,7 @@ describe('register_group success', () => { name: 'Partial', // missing folder and trigger }, - 'whatsapp_main', + 'main', true, deps, ); diff --git a/src/ipc.ts b/src/ipc.ts index 7a972c0..cd6c4f0 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -3,7 +3,12 @@ import path from 'path'; import { CronExpressionParser } from 'cron-parser'; -import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js'; +import { + DATA_DIR, + IPC_POLL_INTERVAL, + MAIN_GROUP_FOLDER, + TIMEZONE, +} from './config.js'; import { AvailableGroup } from './container-runner.js'; import { createTask, deleteTask, getTaskById, updateTask } from './db.js'; import { isValidGroupFolder } from './group-folder.js'; @@ -14,7 +19,7 @@ export interface IpcDeps { sendMessage: (jid: string, text: string) => Promise; registeredGroups: () => Record; registerGroup: (jid: string, group: RegisteredGroup) => void; - syncGroups: (force: boolean) => Promise; + syncGroupMetadata: (force: boolean) => Promise; getAvailableGroups: () => AvailableGroup[]; writeGroupsSnapshot: ( groupFolder: string, @@ -52,14 +57,13 @@ export function startIpcWatcher(deps: IpcDeps): void { const registeredGroups = deps.registeredGroups(); - // Build folder→isMain lookup from registered groups - const folderIsMain = new Map(); - for (const group of Object.values(registeredGroups)) { - if (group.isMain) folderIsMain.set(group.folder, true); - } - for (const sourceGroup of groupFolders) { - const isMain = folderIsMain.get(sourceGroup) === true; + // Strip '-task' suffix so task containers (e.g. 'main-task') are authorized + // as their base group ('main') for all IPC operations. + const baseFolder = sourceGroup.endsWith('-task') + ? sourceGroup.slice(0, -5) + : sourceGroup; + const isMain = baseFolder === MAIN_GROUP_FOLDER; const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages'); const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks'); @@ -78,7 +82,9 @@ export function startIpcWatcher(deps: IpcDeps): void { const targetGroup = registeredGroups[data.chatJid]; if ( isMain || - (targetGroup && targetGroup.folder === sourceGroup) + (targetGroup && + (targetGroup.folder === sourceGroup || + targetGroup.folder === baseFolder)) ) { await deps.sendMessage(data.chatJid, data.text); logger.info( @@ -177,6 +183,9 @@ export async function processTaskIpc( deps: IpcDeps, ): Promise { const registeredGroups = deps.registeredGroups(); + const baseFolder = sourceGroup.endsWith('-task') + ? sourceGroup.slice(0, -5) + : sourceGroup; switch (data.type) { case 'schedule_task': @@ -201,7 +210,11 @@ export async function processTaskIpc( const targetFolder = targetGroupEntry.folder; // Authorization: non-main groups can only schedule for themselves - if (!isMain && targetFolder !== sourceGroup) { + if ( + !isMain && + targetFolder !== sourceGroup && + targetFolder !== baseFolder + ) { logger.warn( { sourceGroup, targetFolder }, 'Unauthorized schedule_task attempt blocked', @@ -236,20 +249,18 @@ export async function processTaskIpc( } nextRun = new Date(Date.now() + ms).toISOString(); } else if (scheduleType === 'once') { - const date = new Date(data.schedule_value); - if (isNaN(date.getTime())) { + const scheduled = new Date(data.schedule_value); + if (isNaN(scheduled.getTime())) { logger.warn( { scheduleValue: data.schedule_value }, 'Invalid timestamp', ); break; } - nextRun = date.toISOString(); + nextRun = scheduled.toISOString(); } - const taskId = - data.taskId || - `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const contextMode = data.context_mode === 'group' || data.context_mode === 'isolated' ? data.context_mode @@ -276,7 +287,12 @@ export async function processTaskIpc( case 'pause_task': if (data.taskId) { const task = getTaskById(data.taskId); - if (task && (isMain || task.group_folder === sourceGroup)) { + if ( + task && + (isMain || + task.group_folder === sourceGroup || + task.group_folder === baseFolder) + ) { updateTask(data.taskId, { status: 'paused' }); logger.info( { taskId: data.taskId, sourceGroup }, @@ -294,7 +310,12 @@ export async function processTaskIpc( case 'resume_task': if (data.taskId) { const task = getTaskById(data.taskId); - if (task && (isMain || task.group_folder === sourceGroup)) { + if ( + task && + (isMain || + task.group_folder === sourceGroup || + task.group_folder === baseFolder) + ) { updateTask(data.taskId, { status: 'active' }); logger.info( { taskId: data.taskId, sourceGroup }, @@ -312,7 +333,12 @@ export async function processTaskIpc( case 'cancel_task': if (data.taskId) { const task = getTaskById(data.taskId); - if (task && (isMain || task.group_folder === sourceGroup)) { + if ( + task && + (isMain || + task.group_folder === sourceGroup || + task.group_folder === baseFolder) + ) { deleteTask(data.taskId); logger.info( { taskId: data.taskId, sourceGroup }, @@ -327,70 +353,6 @@ export async function processTaskIpc( } break; - case 'update_task': - if (data.taskId) { - const task = getTaskById(data.taskId); - if (!task) { - logger.warn( - { taskId: data.taskId, sourceGroup }, - 'Task not found for update', - ); - break; - } - if (!isMain && task.group_folder !== sourceGroup) { - logger.warn( - { taskId: data.taskId, sourceGroup }, - 'Unauthorized task update attempt', - ); - break; - } - - const updates: Parameters[1] = {}; - if (data.prompt !== undefined) updates.prompt = data.prompt; - if (data.schedule_type !== undefined) - updates.schedule_type = data.schedule_type as - | 'cron' - | 'interval' - | 'once'; - if (data.schedule_value !== undefined) - updates.schedule_value = data.schedule_value; - - // Recompute next_run if schedule changed - if (data.schedule_type || data.schedule_value) { - const updatedTask = { - ...task, - ...updates, - }; - if (updatedTask.schedule_type === 'cron') { - try { - const interval = CronExpressionParser.parse( - updatedTask.schedule_value, - { tz: TIMEZONE }, - ); - updates.next_run = interval.next().toISOString(); - } catch { - logger.warn( - { taskId: data.taskId, value: updatedTask.schedule_value }, - 'Invalid cron in task update', - ); - break; - } - } else if (updatedTask.schedule_type === 'interval') { - const ms = parseInt(updatedTask.schedule_value, 10); - if (!isNaN(ms) && ms > 0) { - updates.next_run = new Date(Date.now() + ms).toISOString(); - } - } - } - - updateTask(data.taskId, updates); - logger.info( - { taskId: data.taskId, sourceGroup, updates }, - 'Task updated via IPC', - ); - } - break; - case 'refresh_groups': // Only main group can request a refresh if (isMain) { @@ -398,7 +360,7 @@ export async function processTaskIpc( { sourceGroup }, 'Group metadata refresh requested via IPC', ); - await deps.syncGroups(true); + await deps.syncGroupMetadata(true); // Write updated snapshot immediately const availableGroups = deps.getAvailableGroups(); deps.writeGroupsSnapshot( @@ -432,7 +394,6 @@ export async function processTaskIpc( ); break; } - // Defense in depth: agent cannot set isMain via IPC deps.registerGroup(data.jid, { name: data.name, folder: data.folder, diff --git a/src/router.ts b/src/router.ts index c14ca89..3c9fbc0 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,5 +1,4 @@ import { Channel, NewMessage } from './types.js'; -import { formatLocalTime } from './timezone.js'; export function escapeXml(s: string): string { if (!s) return ''; @@ -10,18 +9,12 @@ export function escapeXml(s: string): string { .replace(/"/g, '"'); } -export function formatMessages( - messages: NewMessage[], - timezone: string, -): string { - const lines = messages.map((m) => { - const displayTime = formatLocalTime(m.timestamp, timezone); - return `${escapeXml(m.content)}`; - }); - - const header = `\n`; - - return `${header}\n${lines.join('\n')}\n`; +export function formatMessages(messages: NewMessage[]): string { + const lines = messages.map( + (m) => + `${escapeXml(m.content)}`, + ); + return `\n${lines.join('\n')}\n`; } export function stripInternalTags(text: string): string { diff --git a/src/routing.test.ts b/src/routing.test.ts index 32bfc1f..3172920 100644 --- a/src/routing.test.ts +++ b/src/routing.test.ts @@ -22,6 +22,16 @@ describe('JID ownership patterns', () => { const jid = '12345678@s.whatsapp.net'; expect(jid.endsWith('@s.whatsapp.net')).toBe(true); }); + + it('Telegram JID: starts with tg:', () => { + const jid = 'tg:123456789'; + expect(jid.startsWith('tg:')).toBe(true); + }); + + it('Telegram group JID: starts with tg: and has negative ID', () => { + const jid = 'tg:-1001234567890'; + expect(jid.startsWith('tg:')).toBe(true); + }); }); // --- getAvailableGroups --- @@ -167,4 +177,103 @@ describe('getAvailableGroups', () => { const groups = getAvailableGroups(); expect(groups).toHaveLength(0); }); + + it('includes Telegram chat JIDs', () => { + storeChatMetadata( + 'tg:100200300', + '2024-01-01T00:00:01.000Z', + 'Telegram Chat', + 'telegram', + true, + ); + storeChatMetadata( + 'user@s.whatsapp.net', + '2024-01-01T00:00:02.000Z', + 'User DM', + 'whatsapp', + false, + ); + + const groups = getAvailableGroups(); + expect(groups).toHaveLength(1); + expect(groups[0].jid).toBe('tg:100200300'); + }); + + it('returns Telegram group JIDs with negative IDs', () => { + storeChatMetadata( + 'tg:-1001234567890', + '2024-01-01T00:00:01.000Z', + 'TG Group', + 'telegram', + true, + ); + + const groups = getAvailableGroups(); + expect(groups).toHaveLength(1); + expect(groups[0].jid).toBe('tg:-1001234567890'); + expect(groups[0].name).toBe('TG Group'); + }); + + it('marks registered Telegram chats correctly', () => { + storeChatMetadata( + 'tg:100200300', + '2024-01-01T00:00:01.000Z', + 'TG Registered', + 'telegram', + true, + ); + storeChatMetadata( + 'tg:999999', + '2024-01-01T00:00:02.000Z', + 'TG Unregistered', + 'telegram', + true, + ); + + _setRegisteredGroups({ + 'tg:100200300': { + name: 'TG Registered', + folder: 'tg-registered', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + }); + + const groups = getAvailableGroups(); + const tgReg = groups.find((g) => g.jid === 'tg:100200300'); + const tgUnreg = groups.find((g) => g.jid === 'tg:999999'); + + expect(tgReg?.isRegistered).toBe(true); + expect(tgUnreg?.isRegistered).toBe(false); + }); + + it('mixes WhatsApp and Telegram chats ordered by activity', () => { + storeChatMetadata( + 'wa@g.us', + '2024-01-01T00:00:01.000Z', + 'WhatsApp', + 'whatsapp', + true, + ); + storeChatMetadata( + 'tg:100', + '2024-01-01T00:00:03.000Z', + 'Telegram', + 'telegram', + true, + ); + storeChatMetadata( + 'wa2@g.us', + '2024-01-01T00:00:02.000Z', + 'WhatsApp 2', + 'whatsapp', + true, + ); + + const groups = getAvailableGroups(); + expect(groups).toHaveLength(3); + expect(groups[0].jid).toBe('tg:100'); + expect(groups[1].jid).toBe('wa2@g.us'); + expect(groups[2].jid).toBe('wa@g.us'); + }); }); diff --git a/src/task-scheduler.test.ts b/src/task-scheduler.test.ts index 2032b51..62129e8 100644 --- a/src/task-scheduler.test.ts +++ b/src/task-scheduler.test.ts @@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { _initTestDatabase, createTask, getTaskById } from './db.js'; import { _resetSchedulerLoopForTests, - computeNextRun, startSchedulerLoop, } from './task-scheduler.js'; @@ -51,79 +50,4 @@ describe('task scheduler', () => { const task = getTaskById('task-invalid-folder'); expect(task?.status).toBe('paused'); }); - - it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => { - const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago - const task = { - id: 'drift-test', - group_folder: 'test', - chat_jid: 'test@g.us', - prompt: 'test', - schedule_type: 'interval' as const, - schedule_value: '60000', // 1 minute - context_mode: 'isolated' as const, - next_run: scheduledTime, - last_run: null, - last_result: null, - status: 'active' as const, - created_at: '2026-01-01T00:00:00.000Z', - }; - - const nextRun = computeNextRun(task); - expect(nextRun).not.toBeNull(); - - // Should be anchored to scheduledTime + 60s, NOT Date.now() + 60s - const expected = new Date(scheduledTime).getTime() + 60000; - expect(new Date(nextRun!).getTime()).toBe(expected); - }); - - it('computeNextRun returns null for once-tasks', () => { - const task = { - id: 'once-test', - group_folder: 'test', - chat_jid: 'test@g.us', - prompt: 'test', - schedule_type: 'once' as const, - schedule_value: '2026-01-01T00:00:00.000Z', - context_mode: 'isolated' as const, - next_run: new Date(Date.now() - 1000).toISOString(), - last_run: null, - last_result: null, - status: 'active' as const, - created_at: '2026-01-01T00:00:00.000Z', - }; - - expect(computeNextRun(task)).toBeNull(); - }); - - it('computeNextRun skips missed intervals without infinite loop', () => { - // Task was due 10 intervals ago (missed) - const ms = 60000; - const missedBy = ms * 10; - const scheduledTime = new Date(Date.now() - missedBy).toISOString(); - - const task = { - id: 'skip-test', - group_folder: 'test', - chat_jid: 'test@g.us', - prompt: 'test', - schedule_type: 'interval' as const, - schedule_value: String(ms), - context_mode: 'isolated' as const, - next_run: scheduledTime, - last_run: null, - last_result: null, - status: 'active' as const, - created_at: '2026-01-01T00:00:00.000Z', - }; - - const nextRun = computeNextRun(task); - expect(nextRun).not.toBeNull(); - // Must be in the future - expect(new Date(nextRun!).getTime()).toBeGreaterThan(Date.now()); - // Must be aligned to the original schedule grid - const offset = - (new Date(nextRun!).getTime() - new Date(scheduledTime).getTime()) % ms; - expect(offset).toBe(0); - }); }); diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index d0abd2e..cd9a084 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -2,7 +2,12 @@ import { ChildProcess } from 'child_process'; import { CronExpressionParser } from 'cron-parser'; import fs from 'fs'; -import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js'; +import { + ASSISTANT_NAME, + MAIN_GROUP_FOLDER, + SCHEDULER_POLL_INTERVAL, + TIMEZONE, +} from './config.js'; import { ContainerOutput, runContainerAgent, @@ -21,56 +26,16 @@ import { resolveGroupFolderPath } from './group-folder.js'; import { logger } from './logger.js'; import { RegisteredGroup, ScheduledTask } from './types.js'; -/** - * Compute the next run time for a recurring task, anchored to the - * task's scheduled time rather than Date.now() to prevent cumulative - * drift on interval-based tasks. - * - * Co-authored-by: @community-pr-601 - */ -export function computeNextRun(task: ScheduledTask): string | null { - if (task.schedule_type === 'once') return null; - - const now = Date.now(); - - if (task.schedule_type === 'cron') { - const interval = CronExpressionParser.parse(task.schedule_value, { - tz: TIMEZONE, - }); - return interval.next().toISOString(); - } - - if (task.schedule_type === 'interval') { - const ms = parseInt(task.schedule_value, 10); - if (!ms || ms <= 0) { - // Guard against malformed interval that would cause an infinite loop - logger.warn( - { taskId: task.id, value: task.schedule_value }, - 'Invalid interval value', - ); - return new Date(now + 60_000).toISOString(); - } - // Anchor to the scheduled time, not now, to prevent drift. - // Skip past any missed intervals so we always land in the future. - let next = new Date(task.next_run!).getTime() + ms; - while (next <= now) { - next += ms; - } - return new Date(next).toISOString(); - } - - return null; -} - export interface SchedulerDependencies { registeredGroups: () => Record; getSessions: () => Record; queue: GroupQueue; onProcess: ( - groupJid: string, + queueKey: string, proc: ChildProcess, containerName: string, groupFolder: string, + ipcFolder?: string, ) => void; sendMessage: (jid: string, text: string) => Promise; } @@ -130,7 +95,7 @@ async function runTask( } // Update tasks snapshot for container to read (filtered by group) - const isMain = group.isMain === true; + const isMain = task.group_folder === MAIN_GROUP_FOLDER; const tasks = getAllTasks(); writeTasksSnapshot( task.group_folder, @@ -160,11 +125,15 @@ async function runTask( const TASK_CLOSE_DELAY_MS = 10000; let closeTimer: ReturnType | null = null; + // Tasks run in a separate queue slot (task:chatJid) and separate IPC dir + // (groupFolder-task/) so they don't block or contaminate user message containers. + const taskQueueKey = `task:${task.chat_jid}`; + const scheduleClose = () => { if (closeTimer) return; // already scheduled closeTimer = setTimeout(() => { logger.debug({ taskId: task.id }, 'Closing task container after result'); - deps.queue.closeStdin(task.chat_jid); + deps.queue.closeStdin(taskQueueKey); }, TASK_CLOSE_DELAY_MS); }; @@ -178,20 +147,29 @@ async function runTask( chatJid: task.chat_jid, isMain, isScheduledTask: true, + ipcSuffix: '-task', assistantName: ASSISTANT_NAME, }, (proc, containerName) => - deps.onProcess(task.chat_jid, proc, containerName, task.group_folder), + deps.onProcess( + taskQueueKey, + proc, + containerName, + task.group_folder, + `${task.group_folder}-task`, + ), async (streamedOutput: ContainerOutput) => { if (streamedOutput.result) { result = streamedOutput.result; // Forward result to user (sendMessage handles formatting) await deps.sendMessage(task.chat_jid, streamedOutput.result); - scheduleClose(); } if (streamedOutput.status === 'success') { - deps.queue.notifyIdle(task.chat_jid); - scheduleClose(); // Close promptly even when result is null (e.g. IPC-only tasks) + // Close task containers on any successful output, not just when + // result has text. Tasks that send output via MCP return result=null + // but are still done — without this they'd hang until hard timeout. + scheduleClose(); + deps.queue.notifyIdle(taskQueueKey); } if (streamedOutput.status === 'error') { error = streamedOutput.error || 'Unknown error'; @@ -204,7 +182,7 @@ async function runTask( if (output.status === 'error') { error = output.error || 'Unknown error'; } else if (output.result) { - // Result was already forwarded to the user via the streaming callback above + // Messages are sent via MCP tool (IPC), result text is just logged result = output.result; } @@ -229,7 +207,18 @@ async function runTask( error, }); - const nextRun = computeNextRun(task); + let nextRun: string | null = null; + if (task.schedule_type === 'cron') { + const interval = CronExpressionParser.parse(task.schedule_value, { + tz: TIMEZONE, + }); + nextRun = interval.next().toISOString(); + } else if (task.schedule_type === 'interval') { + const ms = parseInt(task.schedule_value, 10); + nextRun = new Date(Date.now() + ms).toISOString(); + } + // 'once' tasks have no next run + const resultSummary = error ? `Error: ${error}` : result @@ -262,7 +251,23 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void { continue; } - deps.queue.enqueueTask(currentTask.chat_jid, currentTask.id, () => + // Advance next_run immediately to prevent re-queuing the same task + // while it's still running (task takes longer than the poll interval). + if (currentTask.schedule_type === 'cron') { + const interval = CronExpressionParser.parse( + currentTask.schedule_value, + { tz: TIMEZONE }, + ); + const nextRun = interval.next().toISOString(); + updateTaskAfterRun(currentTask.id, nextRun, ''); + logger.debug( + { taskId: currentTask.id, nextRun }, + 'Advanced next_run before task start', + ); + } + + const taskQueueKey = `task:${currentTask.chat_jid}`; + deps.queue.enqueueTask(taskQueueKey, currentTask.id, () => runTask(currentTask, deps), ); } diff --git a/src/transcription.ts b/src/transcription.ts new file mode 100644 index 0000000..46a15a4 --- /dev/null +++ b/src/transcription.ts @@ -0,0 +1,112 @@ +import { downloadMediaMessage } from '@whiskeysockets/baileys'; +import { WAMessage, WASocket } from '@whiskeysockets/baileys'; + +import { readEnvFile } from './env.js'; +import { logger } from './logger.js'; + +interface TranscriptionConfig { + model: string; + enabled: boolean; + fallbackMessage: string; +} + +const DEFAULT_CONFIG: TranscriptionConfig = { + model: 'gpt-4o-mini-transcribe', + enabled: true, + fallbackMessage: '[Voice Message - transcription unavailable]', +}; + +async function transcribeWithOpenAI( + audioBuffer: Buffer, + config: TranscriptionConfig, +): Promise { + const env = readEnvFile(['OPENAI_API_KEY']); + const apiKey = env.OPENAI_API_KEY; + + if (!apiKey) { + logger.warn('OPENAI_API_KEY not set in .env'); + return null; + } + + try { + const openaiModule = await import('openai'); + const OpenAI = openaiModule.default; + const toFile = openaiModule.toFile; + + const openai = new OpenAI({ apiKey }); + + const file = await toFile(audioBuffer, 'voice.ogg', { + type: 'audio/ogg', + }); + + const transcription = await openai.audio.transcriptions.create({ + file: file, + model: config.model, + response_format: 'text', + }); + + // When response_format is 'text', the API returns a plain string + return transcription as unknown as string; + } catch (err) { + logger.error({ err }, 'OpenAI transcription failed'); + return null; + } +} + +export async function transcribeBuffer( + audioBuffer: Buffer, +): Promise { + const config = DEFAULT_CONFIG; + + if (!config.enabled) { + return null; + } + + const transcript = await transcribeWithOpenAI(audioBuffer, config); + return transcript ? transcript.trim() : null; +} + +export async function transcribeAudioMessage( + msg: WAMessage, + sock: WASocket, +): Promise { + const config = DEFAULT_CONFIG; + + if (!config.enabled) { + return config.fallbackMessage; + } + + try { + const buffer = (await downloadMediaMessage( + msg, + 'buffer', + {}, + { + logger: console as any, + reuploadRequest: sock.updateMediaMessage, + }, + )) as Buffer; + + if (!buffer || buffer.length === 0) { + logger.error('Failed to download audio message'); + return config.fallbackMessage; + } + + logger.info({ bytes: buffer.length }, 'Downloaded audio message'); + + const transcript = await transcribeWithOpenAI(buffer, config); + + if (!transcript) { + return config.fallbackMessage; + } + + return transcript.trim(); + } catch (err) { + logger.error({ err }, 'Transcription error'); + return config.fallbackMessage; + } +} + +export function isVoiceMessage(msg: WAMessage): boolean { + return msg.message?.audioMessage?.ptt === true; +} diff --git a/src/types.ts b/src/types.ts index acbb08a..7038b3a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,7 +39,6 @@ export interface RegisteredGroup { added_at: string; containerConfig?: ContainerConfig; requiresTrigger?: boolean; // Default: true for groups, false for solo chats - isMain?: boolean; // True for the main control group (no trigger, elevated privileges) } export interface NewMessage { @@ -88,8 +87,6 @@ export interface Channel { disconnect(): Promise; // Optional: typing indicator. Channels that support it implement it. setTyping?(jid: string, isTyping: boolean): Promise; - // Optional: sync group/chat names from the platform. - syncGroups?(force: boolean): Promise; } // Callback type that channels use to deliver inbound messages @@ -97,7 +94,7 @@ export type OnInboundMessage = (chatJid: string, message: NewMessage) => void; // Callback for chat metadata discovery. // name is optional — channels that deliver names inline (Telegram) pass it here; -// channels that sync names separately (via syncGroups) omit it. +// channels that sync names separately (WhatsApp syncGroupMetadata) omit it. export type OnChatMetadata = ( chatJid: string, timestamp: string, diff --git a/src/whatsapp-auth.ts b/src/whatsapp-auth.ts new file mode 100644 index 0000000..48545d1 --- /dev/null +++ b/src/whatsapp-auth.ts @@ -0,0 +1,180 @@ +/** + * WhatsApp Authentication Script + * + * Run this during setup to authenticate with WhatsApp. + * Displays QR code, waits for scan, saves credentials, then exits. + * + * Usage: npx tsx src/whatsapp-auth.ts + */ +import fs from 'fs'; +import path from 'path'; +import pino from 'pino'; +import qrcode from 'qrcode-terminal'; +import readline from 'readline'; + +import makeWASocket, { + Browsers, + DisconnectReason, + fetchLatestWaWebVersion, + makeCacheableSignalKeyStore, + useMultiFileAuthState, +} from '@whiskeysockets/baileys'; + +const AUTH_DIR = './store/auth'; +const QR_FILE = './store/qr-data.txt'; +const STATUS_FILE = './store/auth-status.txt'; + +const logger = pino({ + level: 'warn', // Quiet logging - only show errors +}); + +// Check for --pairing-code flag and phone number +const usePairingCode = process.argv.includes('--pairing-code'); +const phoneArg = process.argv.find((_, i, arr) => arr[i - 1] === '--phone'); + +function askQuestion(prompt: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => { + rl.question(prompt, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +async function connectSocket( + phoneNumber?: string, + isReconnect = false, +): Promise { + const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR); + + if (state.creds.registered && !isReconnect) { + fs.writeFileSync(STATUS_FILE, 'already_authenticated'); + console.log('✓ Already authenticated with WhatsApp'); + console.log( + ' To re-authenticate, delete the store/auth folder and run again.', + ); + process.exit(0); + } + + const { version } = await fetchLatestWaWebVersion({}).catch((err) => { + logger.warn( + { err }, + 'Failed to fetch latest WA Web version, using default', + ); + return { version: undefined }; + }); + const sock = makeWASocket({ + version, + auth: { + creds: state.creds, + keys: makeCacheableSignalKeyStore(state.keys, logger), + }, + printQRInTerminal: false, + logger, + browser: Browsers.macOS('Chrome'), + }); + + if (usePairingCode && phoneNumber && !state.creds.me) { + // Request pairing code after a short delay for connection to initialize + // Only on first connect (not reconnect after 515) + setTimeout(async () => { + try { + const code = await sock.requestPairingCode(phoneNumber!); + console.log(`\n🔗 Your pairing code: ${code}\n`); + console.log(' 1. Open WhatsApp on your phone'); + console.log(' 2. Tap Settings → Linked Devices → Link a Device'); + console.log(' 3. Tap "Link with phone number instead"'); + console.log(` 4. Enter this code: ${code}\n`); + fs.writeFileSync(STATUS_FILE, `pairing_code:${code}`); + } catch (err: any) { + console.error('Failed to request pairing code:', err.message); + process.exit(1); + } + }, 3000); + } + + sock.ev.on('connection.update', (update) => { + const { connection, lastDisconnect, qr } = update; + + if (qr) { + // Write raw QR data to file so the setup skill can render it + fs.writeFileSync(QR_FILE, qr); + console.log('Scan this QR code with WhatsApp:\n'); + console.log(' 1. Open WhatsApp on your phone'); + console.log(' 2. Tap Settings → Linked Devices → Link a Device'); + console.log(' 3. Point your camera at the QR code below\n'); + qrcode.generate(qr, { small: true }); + } + + if (connection === 'close') { + const reason = (lastDisconnect?.error as any)?.output?.statusCode; + + if (reason === DisconnectReason.loggedOut) { + fs.writeFileSync(STATUS_FILE, 'failed:logged_out'); + console.log('\n✗ Logged out. Delete store/auth and try again.'); + process.exit(1); + } else if (reason === DisconnectReason.timedOut) { + fs.writeFileSync(STATUS_FILE, 'failed:qr_timeout'); + console.log('\n✗ QR code timed out. Please try again.'); + process.exit(1); + } else if (reason === 515) { + // 515 = stream error, often happens after pairing succeeds but before + // registration completes. Reconnect to finish the handshake. + console.log('\n⟳ Stream error (515) after pairing — reconnecting...'); + connectSocket(phoneNumber, true); + } else { + fs.writeFileSync(STATUS_FILE, `failed:${reason || 'unknown'}`); + console.log('\n✗ Connection failed. Please try again.'); + process.exit(1); + } + } + + if (connection === 'open') { + fs.writeFileSync(STATUS_FILE, 'authenticated'); + // Clean up QR file now that we're connected + try { + fs.unlinkSync(QR_FILE); + } catch {} + console.log('\n✓ Successfully authenticated with WhatsApp!'); + console.log(' Credentials saved to store/auth/'); + console.log(' You can now start the NanoClaw service.\n'); + + // Give it a moment to save credentials, then exit + setTimeout(() => process.exit(0), 1000); + } + }); + + sock.ev.on('creds.update', saveCreds); +} + +async function authenticate(): Promise { + fs.mkdirSync(AUTH_DIR, { recursive: true }); + + // Clean up any stale QR/status files from previous runs + try { + fs.unlinkSync(QR_FILE); + } catch {} + try { + fs.unlinkSync(STATUS_FILE); + } catch {} + + let phoneNumber = phoneArg; + if (usePairingCode && !phoneNumber) { + phoneNumber = await askQuestion( + 'Enter your phone number (with country code, no + or spaces, e.g. 14155551234): ', + ); + } + + console.log('Starting WhatsApp authentication...\n'); + + await connectSocket(phoneNumber); +} + +authenticate().catch((err) => { + console.error('Authentication failed:', err.message); + process.exit(1); +}); diff --git a/vitest.config.ts b/vitest.config.ts index a456d1c..354e6a5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['src/**/*.test.ts', 'setup/**/*.test.ts'], + include: ['src/**/*.test.ts', 'setup/**/*.test.ts', 'skills-engine/**/*.test.ts'], }, });