diff --git a/package.json b/package.json index 9f2ce3147..0ee131f73 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "dependencies": { "@buape/carbon": "0.0.0-beta-20260110172854", "@clack/prompts": "^0.11.0", + "@google-cloud/pubsub": "^5.2.1", "@grammyjs/runner": "^2.0.3", "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.4", @@ -153,6 +154,7 @@ "dotenv": "^17.2.3", "express": "^5.2.1", "file-type": "^21.3.0", + "googleapis": "^170.0.0", "grammy": "^1.39.2", "jiti": "^2.6.1", "json5": "^2.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1de373b74..3f4e63c14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@clack/prompts': specifier: ^0.11.0 version: 0.11.0 + '@google-cloud/pubsub': + specifier: ^5.2.1 + version: 5.2.1 '@grammyjs/runner': specifier: ^2.0.3 version: 2.0.3(grammy@1.39.2) @@ -100,6 +103,9 @@ importers: file-type: specifier: ^21.3.0 version: 21.3.0 + googleapis: + specifier: ^170.0.0 + version: 170.0.0 grammy: specifier: ^1.39.2 version: 1.39.2 @@ -220,7 +226,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.16 - version: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) wireit: specifier: ^0.14.12 version: 0.14.12 @@ -251,7 +257,7 @@ importers: version: 5.9.3 vitest: specifier: 4.0.16 - version: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -566,6 +572,26 @@ packages: '@glideapps/ts-necessities@2.2.3': resolution: {integrity: sha512-gXi0awOZLHk3TbW55GZLCPP6O+y/b5X1pBXKBVckFONSwF1z1E5ND2BGJsghQFah+pW7pkkyFb2VhUQI2qhL5w==} + '@google-cloud/paginator@6.0.0': + resolution: {integrity: sha512-g5nmMnzC+94kBxOKkLGpK1ikvolTFCC3s2qtE4F+1EuArcJ7HHC23RDQVt3Ra3CqpUYZ+oXNKZ8n5Cn5yug8DA==} + engines: {node: '>=18'} + + '@google-cloud/precise-date@5.0.0': + resolution: {integrity: sha512-9h0Gvw92EvPdE8AK8AgZPbMnH5ftDyPtKm7/KUfcJVaPEPjwGDsJd1QV0H8esBDV4II41R/2lDWH1epBqIoKUw==} + engines: {node: '>=18'} + + '@google-cloud/projectify@5.0.0': + resolution: {integrity: sha512-XXQLaIcLrOAMWvRrzz+mlUGtN6vlVNja3XQbMqRi/V7XJTAVwib3VcKd7oRwyZPkp7rBVlHGcaqdyGRrcnkhlA==} + engines: {node: '>=18'} + + '@google-cloud/promisify@5.0.0': + resolution: {integrity: sha512-N8qS6dlORGHwk7WjGXKOSsLjIjNINCPicsOX6gyyLiYk7mq3MtII96NZ9N2ahwA2vnkLmZODOIH9rlNniYWvCQ==} + engines: {node: '>=18'} + + '@google-cloud/pubsub@5.2.1': + resolution: {integrity: sha512-AGuH/dN8059pLg1A4vMLFQfZEuIwLy1Ykmh8b4XmBM6eT93mAgmrV2y2JMq6dRWTIaBpYGFA7NjRviNL/W/fmQ==} + engines: {node: '>=18'} + '@google/genai@1.34.0': resolution: {integrity: sha512-vu53UMPvjmb7PGzlYu6Tzxso8Dfhn+a7eQFaS2uNemVtDZKwzSpJ5+ikqBbXplF7RGB1STcVDqCkPvquiwb2sw==} engines: {node: '>=20.0.0'} @@ -590,6 +616,15 @@ packages: '@grammyjs/types@3.23.0': resolution: {integrity: sha512-D3jQ4UWERPsyR3op/YFudMMIPNTU47vy7L51uO9/73tMELmjO/+LX5N36/Y0CG5IQfIsz43MxiHI5rgsK0/k+g==} + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + '@hapi/boom@9.1.4': resolution: {integrity: sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==} @@ -765,6 +800,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@keyv/bigmap@1.3.0': resolution: {integrity: sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==} engines: {node: '>= 18'} @@ -900,6 +938,24 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@1.30.1': + resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.28.0': + resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==} + engines: {node: '>=14'} + + '@opentelemetry/semantic-conventions@1.34.0': + resolution: {integrity: sha512-aKcOkyrorBGlajjRdVoJWHTxfxO1vCNHLJVlSDaRHDIdjU+pX8IYQPvPDkYiujKLbRnWU+1TBwEt0QRgSm4SGA==} + engines: {node: '>=14'} + '@oxc-project/types@0.107.0': resolution: {integrity: sha512-QFDRbYfV2LVx8tyqtyiah3jQPUj1mK2+RYwxyFWyGoys6XJnwTdlzO6rdNNHOPorHAu5Uo34oWRKcvNpbJarmQ==} @@ -1278,6 +1334,10 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1487,6 +1547,10 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -1538,6 +1602,10 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + arrify@2.0.1: + resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} + engines: {node: '>=8'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1669,6 +1737,10 @@ packages: cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1790,6 +1862,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -1809,6 +1884,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -2000,10 +2078,22 @@ packages: resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} engines: {node: '>=18'} + google-gax@5.0.6: + resolution: {integrity: sha512-1kGbqVQBZPAAu4+/R1XxPQKP0ydbNYoLAr4l0ZO2bMV0kLyLW4I1gAk++qBLWt7DPORTzmWRMsCZe86gDjShJA==} + engines: {node: '>=18'} + google-logging-utils@1.1.3: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} + googleapis-common@8.0.1: + resolution: {integrity: sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==} + engines: {node: '>=18.0.0'} + + googleapis@170.0.0: + resolution: {integrity: sha512-UJz71WZ3ubMr4NhkEU+CFTS0CMrrXq+ltrFnAQo8Llf9M3cy0AIfKLyFQdUJyhqIpJ4jPW4SRCcBBntrLQ72/A==} + engines: {node: '>=18'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2039,6 +2129,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + heap-js@2.7.1: + resolution: {integrity: sha512-EQfezRg0NCZGNlhlDR3Evrw1FVL2G3LhU7EgPoxufQKruNBSYA8MiRPHeWbU+36o+Fhel0wMwM+sLEiBAlNLJA==} + engines: {node: '>=10.0.0'} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -2063,10 +2157,18 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -2114,6 +2216,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream-ended@0.1.4: + resolution: {integrity: sha512-xj0XPvmr7bQFTvirqnFr50o0hQIh6ZItDqloxt5aJrR4NQsYeSsyFQERYGCAzfindAcnKjINnwEEgLx4IqVzQw==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -2296,6 +2401,9 @@ packages: lit@3.3.2: resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.clonedeep@4.5.0: resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} @@ -2320,6 +2428,9 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -2492,6 +2603,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -2553,6 +2668,10 @@ packages: oxlint-tsgolint: optional: true + p-defer@3.0.0: + resolution: {integrity: sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==} + engines: {node: '>=8'} + p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} @@ -2701,6 +2820,10 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + proto3-json-serializer@3.0.4: + resolution: {integrity: sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==} + engines: {node: '>=18'} + protobufjs@6.8.8: resolution: {integrity: sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==} hasBin: true @@ -2758,6 +2881,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readable-stream@4.5.2: resolution: {integrity: sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2785,6 +2912,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + retry-request@8.0.2: + resolution: {integrity: sha512-JzFPAfklk1kjR1w76f0QOIhoDkNkSqW8wYKT08n9yysTmZfB+RQ2QoXoTAeOi1HD9ZipTyTAZg3c4pM/jeqgSw==} + engines: {node: '>=18'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -2934,6 +3065,12 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stream-events@1.0.5: + resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2960,6 +3097,9 @@ packages: resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} engines: {node: '>=18'} + stubs@3.0.0: + resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2980,6 +3120,10 @@ packages: tailwindcss@4.1.17: resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==} + teeny-request@10.1.0: + resolution: {integrity: sha512-3ZnLvgWF29jikg1sAQ1g0o+lr5JX6sVgYvfUJazn7ZjJroDBUTWp44/+cFVX0bULjv4vci+rBD+oGVAkWqhUbw==} + engines: {node: '>=18'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -3085,6 +3229,9 @@ packages: urijs@1.19.11: resolution: {integrity: sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==} + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -3256,10 +3403,18 @@ packages: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -3538,6 +3693,36 @@ snapshots: '@glideapps/ts-necessities@2.2.3': {} + '@google-cloud/paginator@6.0.0': + dependencies: + extend: 3.0.2 + + '@google-cloud/precise-date@5.0.0': {} + + '@google-cloud/projectify@5.0.0': {} + + '@google-cloud/promisify@5.0.0': {} + + '@google-cloud/pubsub@5.2.1': + dependencies: + '@google-cloud/paginator': 6.0.0 + '@google-cloud/precise-date': 5.0.0 + '@google-cloud/projectify': 5.0.0 + '@google-cloud/promisify': 5.0.0 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.34.0 + arrify: 2.0.1 + extend: 3.0.2 + google-auth-library: 10.5.0 + google-gax: 5.0.6 + heap-js: 2.7.1 + is-stream-ended: 0.1.4 + lodash.snakecase: 4.1.1 + p-defer: 3.0.0 + transitivePeerDependencies: + - supports-color + '@google/genai@1.34.0': dependencies: google-auth-library: 10.5.0 @@ -3559,6 +3744,18 @@ snapshots: '@grammyjs/types@3.23.0': {} + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + '@hapi/boom@9.1.4': dependencies: '@hapi/hoek': 9.3.0 @@ -3699,6 +3896,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + '@keyv/bigmap@1.3.0(keyv@5.5.5)': dependencies: hashery: 1.4.0 @@ -3902,6 +4101,17 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/semantic-conventions@1.28.0': {} + + '@opentelemetry/semantic-conventions@1.34.0': {} + '@oxc-project/types@0.107.0': {} '@oxlint-tsgolint/darwin-arm64@0.11.0': @@ -4199,6 +4409,8 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@tootallnate/once@2.0.0': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -4345,7 +4557,7 @@ snapshots: '@vitest/mocker': 4.0.16(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) playwright: 1.57.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - bufferutil - msw @@ -4357,7 +4569,7 @@ snapshots: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) '@vitest/browser': 4.0.16(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16) - vitest: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - bufferutil - msw @@ -4374,7 +4586,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) ws: 8.19.0 transitivePeerDependencies: - bufferutil @@ -4395,7 +4607,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@vitest/browser': 4.0.16(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16) transitivePeerDependencies: @@ -4501,6 +4713,12 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} ajv-formats@3.0.1(ajv@8.17.1): @@ -4543,6 +4761,8 @@ snapshots: dequal: 2.0.3 optional: true + arrify@2.0.1: {} + assertion-error@2.0.1: {} ast-v8-to-istanbul@0.3.10: @@ -4709,6 +4929,12 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} codec-parser@2.5.0: @@ -4798,6 +5024,13 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + eastasianwidth@0.2.0: {} ecdsa-sig-formatter@1.0.11: @@ -4812,6 +5045,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + entities@4.5.0: {} es-define-property@1.0.1: {} @@ -5072,8 +5309,41 @@ snapshots: transitivePeerDependencies: - supports-color + google-gax@5.0.6: + dependencies: + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.8.0 + duplexify: 4.1.3 + google-auth-library: 10.5.0 + google-logging-utils: 1.1.3 + node-fetch: 3.3.2 + object-hash: 3.0.0 + proto3-json-serializer: 3.0.4 + protobufjs: 7.5.4 + retry-request: 8.0.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + google-logging-utils@1.1.3: {} + googleapis-common@8.0.1: + dependencies: + extend: 3.0.2 + gaxios: 7.1.3 + google-auth-library: 10.5.0 + qs: 6.14.1 + url-template: 2.0.8 + transitivePeerDependencies: + - supports-color + + googleapis@170.0.0: + dependencies: + google-auth-library: 10.5.0 + googleapis-common: 8.0.1 + transitivePeerDependencies: + - supports-color + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -5111,6 +5381,8 @@ snapshots: dependencies: function-bind: 1.1.2 + heap-js@2.7.1: {} + highlight.js@10.7.3: {} highlight.js@11.11.1: {} @@ -5132,6 +5404,14 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -5139,6 +5419,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -5176,6 +5463,8 @@ snapshots: is-promise@4.0.0: {} + is-stream-ended@0.1.4: {} + is-stream@2.0.1: {} is-url@1.2.4: {} @@ -5367,6 +5656,8 @@ snapshots: lit-element: 4.2.2 lit-html: 3.3.2 + lodash.camelcase@4.3.0: {} + lodash.clonedeep@4.5.0: {} lodash.includes@4.3.0: {} @@ -5383,6 +5674,8 @@ snapshots: lodash.once@4.1.1: {} + lodash.snakecase@4.1.1: {} + lodash@4.17.21: {} long@4.0.0: {} @@ -5529,6 +5822,8 @@ snapshots: object-assign@4.1.1: {} + object-hash@3.0.0: {} + object-inspect@1.13.4: {} object-path@0.11.8: {} @@ -5590,6 +5885,8 @@ snapshots: '@oxlint/win32-x64': 1.38.0 oxlint-tsgolint: 0.11.0 + p-defer@3.0.0: {} + p-finally@1.0.0: {} p-queue@6.6.2: @@ -5717,6 +6014,10 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 + proto3-json-serializer@3.0.4: + dependencies: + protobufjs: 7.5.4 + protobufjs@6.8.8: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -5817,6 +6118,12 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readable-stream@4.5.2: dependencies: abort-controller: 3.0.0 @@ -5839,6 +6146,13 @@ snapshots: resolve-pkg-maps@1.0.0: {} + retry-request@8.0.2: + dependencies: + extend: 3.0.2 + teeny-request: 10.1.0 + transitivePeerDependencies: + - supports-color + retry@0.12.0: {} retry@0.13.1: {} @@ -6061,6 +6375,12 @@ snapshots: std-env@3.10.0: {} + stream-events@1.0.5: + dependencies: + stubs: 3.0.0 + + stream-shift@1.0.3: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -6093,6 +6413,8 @@ snapshots: dependencies: '@tokenizer/token': 0.3.0 + stubs@3.0.0: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -6107,6 +6429,15 @@ snapshots: tailwindcss@4.1.17: {} + teeny-request@10.1.0: + dependencies: + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + node-fetch: 3.3.2 + stream-events: 1.0.5 + transitivePeerDependencies: + - supports-color + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -6197,6 +6528,8 @@ snapshots: urijs@1.19.11: {} + url-template@2.0.8: {} + util-deprecate@1.0.2: {} uuid@11.1.0: {} @@ -6221,7 +6554,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitest@4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.16 '@vitest/mocker': 4.0.16(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) @@ -6244,6 +6577,7 @@ snapshots: vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.0 '@types/node': 25.0.3 '@vitest/browser-playwright': 4.0.16(playwright@1.57.0)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16) '@vitest/browser-preview': 4.0.16(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16) @@ -6317,6 +6651,8 @@ snapshots: yargs-parser@20.2.9: {} + yargs-parser@21.1.1: {} + yargs@16.2.0: dependencies: cliui: 7.0.4 @@ -6327,6 +6663,16 @@ snapshots: y18n: 5.0.8 yargs-parser: 20.2.9 + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/src/config/types.ts b/src/config/types.ts index 9bf8b6f06..4fd780762 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -746,6 +746,40 @@ export type MSTeamsTeamConfig = { channels?: Record; }; +export type GoogleChatAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** If false, do not start this Google Chat account. Default: true. */ + enabled?: boolean; + /** Google Cloud Project ID. */ + projectId?: string; + /** Pub/Sub subscription name (full path: projects/.../subscriptions/...). */ + subscriptionName?: string; + /** Path to service account credentials JSON file. */ + credentialsPath?: string; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; + /** Allowlist for DM senders (email addresses). */ + allowFrom?: string[]; + /** Group/space access policy (default: disabled). */ + spacePolicy?: GroupPolicy; + /** Allowlist for spaces (space IDs). */ + allowSpaces?: string[]; + /** Max space messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Outbound message prefix. */ + messagePrefix?: string; +}; + +export type GoogleChatConfig = { + /** Optional per-account Google Chat configuration (multi-account). */ + accounts?: Record; +} & GoogleChatAccountConfig; + export type MSTeamsConfig = { /** If false, do not start the MS Teams provider. Default: true. */ enabled?: boolean; @@ -1655,6 +1689,7 @@ export type ClawdbotConfig = { signal?: SignalConfig; imessage?: IMessageConfig; msteams?: MSTeamsConfig; + googlechat?: GoogleChatConfig; cron?: CronConfig; hooks?: HooksConfig; bridge?: BridgeConfig; diff --git a/src/googlechat/accounts.ts b/src/googlechat/accounts.ts new file mode 100644 index 000000000..4a848515a --- /dev/null +++ b/src/googlechat/accounts.ts @@ -0,0 +1,89 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../routing/session-key.js"; +import type { GoogleChatAccountConfig } from "./types.js"; + +export type ResolvedGoogleChatAccount = { + accountId: string; + enabled: boolean; + name?: string; + projectId: string; + subscriptionName: string; + credentialsPath?: string; + config: GoogleChatAccountConfig; +}; + +function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] { + const googlechat = (cfg as { googlechat?: { accounts?: Record } }).googlechat; + const accounts = googlechat?.accounts; + if (!accounts || typeof accounts !== "object") return []; + return Object.keys(accounts).filter(Boolean); +} + +export function listGoogleChatAccountIds(cfg: ClawdbotConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; + return ids.sort((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultGoogleChatAccountId(cfg: ClawdbotConfig): string { + const ids = listGoogleChatAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): GoogleChatAccountConfig | undefined { + const googlechat = (cfg as { googlechat?: { accounts?: Record } }).googlechat; + const accounts = googlechat?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + return accounts[accountId]; +} + +function mergeGoogleChatAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): GoogleChatAccountConfig { + const googlechat = (cfg as { googlechat?: GoogleChatAccountConfig & { accounts?: unknown } }).googlechat ?? {}; + const { accounts: _ignored, ...base } = googlechat; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +export function resolveGoogleChatAccount(params: { + cfg: ClawdbotConfig; + accountId?: string | null; +}): ResolvedGoogleChatAccount { + const googlechat = (params.cfg as { googlechat?: { enabled?: boolean } }).googlechat; + const baseEnabled = googlechat?.enabled !== false; + + const resolve = (accountId: string) => { + const merged = mergeGoogleChatAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + projectId: merged.projectId ?? "", + subscriptionName: merged.subscriptionName ?? "", + credentialsPath: merged.credentialsPath, + config: merged, + } satisfies ResolvedGoogleChatAccount; + }; + + const normalized = normalizeAccountId(params.accountId); + return resolve(normalized); +} + +export function listEnabledGoogleChatAccounts( + cfg: ClawdbotConfig, +): ResolvedGoogleChatAccount[] { + return listGoogleChatAccountIds(cfg) + .map((accountId) => resolveGoogleChatAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/src/googlechat/monitor.ts b/src/googlechat/monitor.ts new file mode 100644 index 000000000..e6b5109c3 --- /dev/null +++ b/src/googlechat/monitor.ts @@ -0,0 +1,184 @@ +import { PubSub, type Message } from "@google-cloud/pubsub"; +import type { ClawdbotConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { ResolvedGoogleChatAccount } from "./accounts.js"; +import type { GoogleChatEvent, GoogleChatMessage } from "./types.js"; + +export type GoogleChatMonitorOptions = { + account: ResolvedGoogleChatAccount; + config: ClawdbotConfig; + runtime: RuntimeEnv; + abortSignal: AbortSignal; +}; + +export type NormalizedGoogleChatMessage = { + provider: "googlechat"; + accountId: string; + messageId: string; + timestamp: number; + sender: { + id: string; + name: string; + email?: string; + }; + chat: { + id: string; + name: string; + type: "dm" | "group"; + }; + thread?: { + id: string; + }; + content: { + text: string; + }; + raw: GoogleChatEvent; +}; + +function normalizeMessage( + event: GoogleChatEvent, + accountId: string, +): NormalizedGoogleChatMessage | null { + if (event.type !== "MESSAGE" || !event.message) { + return null; + } + + const msg = event.message; + + // Skip messages from bots + if (msg.sender.type === "BOT") { + return null; + } + + const spaceId = msg.space.name.replace("spaces/", ""); + const isDM = msg.space.type === "DM"; + + return { + provider: "googlechat", + accountId, + messageId: msg.name, + timestamp: new Date(msg.createTime).getTime(), + sender: { + id: msg.sender.name.replace("users/", ""), + name: msg.sender.displayName, + email: msg.sender.email, + }, + chat: { + id: spaceId, + name: msg.space.displayName || spaceId, + type: isDM ? "dm" : "group", + }, + thread: msg.thread + ? { + id: msg.thread.name, + } + : undefined, + content: { + text: msg.argumentText || msg.text || "", + }, + raw: event, + }; +} + +function checkMessagePolicy( + message: NormalizedGoogleChatMessage, + account: ResolvedGoogleChatAccount, +): boolean { + const policy = + message.chat.type === "dm" + ? account.config.dmPolicy ?? "pairing" + : account.config.spacePolicy ?? "disabled"; + + switch (policy) { + case "disabled": + return false; + case "open": + return true; + case "allowlist": { + if (message.chat.type === "dm") { + const allowFrom = account.config.allowFrom ?? []; + return allowFrom.includes(message.sender.email ?? ""); + } + const allowSpaces = account.config.allowSpaces ?? []; + return allowSpaces.includes(message.chat.id); + } + case "pairing": + // For pairing mode, we'd need to check the pairing store + // For now, treat as allowlist-based + return false; + default: + return false; + } +} + +export async function monitorGoogleChatProvider( + options: GoogleChatMonitorOptions, +): Promise<() => void> { + const { account, abortSignal } = options; + + if (!account.projectId || !account.subscriptionName) { + throw new Error( + `Google Chat account ${account.accountId} missing projectId or subscriptionName`, + ); + } + + const pubsub = new PubSub({ + projectId: account.projectId, + keyFilename: account.credentialsPath, + }); + + const subscription = pubsub.subscription(account.subscriptionName); + + const messageHandler = async (message: Message) => { + try { + const eventData = JSON.parse(message.data.toString()) as GoogleChatEvent; + + const normalized = normalizeMessage(eventData, account.accountId); + + if (normalized) { + const allowed = checkMessagePolicy(normalized, account); + if (allowed) { + // Route to agent - this will be wired up by the gateway + // For now, we just acknowledge the message + console.log( + `[googlechat:${account.accountId}] Received message from ${normalized.sender.name}`, + ); + } + } + + message.ack(); + } catch (error) { + console.error( + `[googlechat:${account.accountId}] Error processing message:`, + error, + ); + message.nack(); + } + }; + + const errorHandler = (error: Error) => { + console.error(`[googlechat:${account.accountId}] Subscription error:`, error); + }; + + subscription.on("message", messageHandler); + subscription.on("error", errorHandler); + + // Handle abort signal + const cleanup = () => { + subscription.removeListener("message", messageHandler); + subscription.removeListener("error", errorHandler); + console.log(`[googlechat:${account.accountId}] Monitor stopped`); + }; + + if (abortSignal.aborted) { + cleanup(); + } else { + abortSignal.addEventListener("abort", cleanup, { once: true }); + } + + console.log( + `[googlechat:${account.accountId}] Listening on subscription: ${account.subscriptionName}`, + ); + + return cleanup; +} diff --git a/src/googlechat/probe.ts b/src/googlechat/probe.ts new file mode 100644 index 000000000..47d004e30 --- /dev/null +++ b/src/googlechat/probe.ts @@ -0,0 +1,69 @@ +import { PubSub } from "@google-cloud/pubsub"; +import type { ResolvedGoogleChatAccount } from "./accounts.js"; + +export type GoogleChatProbeResult = { + ok: boolean; + status: string; + projectId?: string; + subscriptionName?: string; + topic?: string; + error?: string; +}; + +export async function probeGoogleChat( + account: ResolvedGoogleChatAccount, + timeoutMs = 5000, +): Promise { + if (!account.projectId || !account.subscriptionName) { + return { + ok: false, + status: "Not configured", + error: "Missing projectId or subscriptionName", + }; + } + + try { + const pubsub = new PubSub({ + projectId: account.projectId, + keyFilename: account.credentialsPath, + }); + + const subscription = pubsub.subscription(account.subscriptionName); + + // Use a timeout for the exists check + const existsPromise = subscription.exists(); + const timeoutPromise = new Promise<[boolean]>((_, reject) => + setTimeout(() => reject(new Error("Timeout")), timeoutMs), + ); + + const [exists] = await Promise.race([existsPromise, timeoutPromise]); + + if (!exists) { + return { + ok: false, + status: "Subscription not found", + projectId: account.projectId, + subscriptionName: account.subscriptionName, + }; + } + + const [metadata] = await subscription.getMetadata(); + + return { + ok: true, + status: "Connected", + projectId: account.projectId, + subscriptionName: account.subscriptionName, + topic: metadata.topic ?? undefined, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + ok: false, + status: "Connection failed", + projectId: account.projectId, + subscriptionName: account.subscriptionName, + error: errorMessage, + }; + } +} diff --git a/src/googlechat/send.ts b/src/googlechat/send.ts new file mode 100644 index 000000000..11df59036 --- /dev/null +++ b/src/googlechat/send.ts @@ -0,0 +1,171 @@ +import { google, type chat_v1 } from "googleapis"; +import type { ResolvedGoogleChatAccount } from "./accounts.js"; + +let chatClients: Map = new Map(); + +async function getChatClient(account: ResolvedGoogleChatAccount): Promise { + const cacheKey = `${account.accountId}:${account.credentialsPath ?? "default"}`; + const cached = chatClients.get(cacheKey); + if (cached) return cached; + + const auth = new google.auth.GoogleAuth({ + keyFile: account.credentialsPath, + scopes: ["https://www.googleapis.com/auth/chat.bot"], + }); + + const client = google.chat({ + version: "v1", + auth, + }); + + chatClients.set(cacheKey, client); + return client; +} + +export type SendGoogleChatResult = { + messageId: string; + spaceName: string; +}; + +export async function sendGoogleChatText( + to: string, + text: string, + options: { + account: ResolvedGoogleChatAccount; + threadKey?: string; + replyToId?: string; + }, +): Promise { + const client = await getChatClient(options.account); + + const spaceName = to.startsWith("spaces/") ? to : `spaces/${to}`; + + const prefix = options.account.config.messagePrefix; + const formattedText = prefix ? `${prefix} ${text}` : text; + + const requestBody: chat_v1.Schema$Message = { + text: formattedText, + }; + + if (options.threadKey) { + requestBody.thread = { name: options.threadKey }; + } + + const response = await client.spaces.messages.create({ + parent: spaceName, + requestBody, + }); + + return { + messageId: response.data.name ?? "", + spaceName, + }; +} + +export async function sendGoogleChatCard( + to: string, + card: chat_v1.Schema$CardWithId, + options: { + account: ResolvedGoogleChatAccount; + threadKey?: string; + text?: string; + }, +): Promise { + const client = await getChatClient(options.account); + + const spaceName = to.startsWith("spaces/") ? to : `spaces/${to}`; + + const requestBody: chat_v1.Schema$Message = { + cardsV2: [card], + }; + + if (options.text) { + requestBody.text = options.text; + } + + if (options.threadKey) { + requestBody.thread = { name: options.threadKey }; + } + + const response = await client.spaces.messages.create({ + parent: spaceName, + requestBody, + }); + + return { + messageId: response.data.name ?? "", + spaceName, + }; +} + +export async function sendGoogleChatMedia( + to: string, + mediaUrl: string, + options: { + account: ResolvedGoogleChatAccount; + caption?: string; + threadKey?: string; + }, +): Promise { + // Google Chat doesn't support direct media upload via API for bots + // Send as a card with image + const card: chat_v1.Schema$CardWithId = { + cardId: `media-${Date.now()}`, + card: { + sections: [ + { + widgets: [ + { + image: { + imageUrl: mediaUrl, + altText: options.caption ?? "Image", + }, + }, + ], + }, + ], + }, + }; + + if (options.caption) { + card.card!.sections![0].widgets!.unshift({ + textParagraph: { text: options.caption }, + }); + } + + return sendGoogleChatCard(to, card, { + account: options.account, + threadKey: options.threadKey, + }); +} + +/** + * Chunk text for Google Chat (4096 char limit). + */ +export function chunkGoogleChatText(text: string, maxLength = 4000): string[] { + if (text.length <= maxLength) return [text]; + + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > 0) { + if (remaining.length <= maxLength) { + chunks.push(remaining); + break; + } + + // Find last newline or space within limit + let splitAt = remaining.lastIndexOf("\n", maxLength); + if (splitAt === -1 || splitAt < maxLength / 2) { + splitAt = remaining.lastIndexOf(" ", maxLength); + } + if (splitAt === -1 || splitAt < maxLength / 2) { + splitAt = maxLength; + } + + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).trimStart(); + } + + return chunks; +} diff --git a/src/googlechat/types.ts b/src/googlechat/types.ts new file mode 100644 index 000000000..73a2ac14d --- /dev/null +++ b/src/googlechat/types.ts @@ -0,0 +1,79 @@ +import type { DmPolicy, GroupPolicy } from "../config/types.js"; + +export type GoogleChatAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** If false, do not start this Google Chat account. Default: true. */ + enabled?: boolean; + /** Google Cloud Project ID. */ + projectId?: string; + /** Pub/Sub subscription name (full path: projects/.../subscriptions/...). */ + subscriptionName?: string; + /** Path to service account credentials JSON file. */ + credentialsPath?: string; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; + /** Allowlist for DM senders (email addresses). */ + allowFrom?: string[]; + /** Group/space access policy (default: disabled). */ + spacePolicy?: GroupPolicy; + /** Allowlist for spaces (space IDs). */ + allowSpaces?: string[]; + /** Max space messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Outbound message prefix. */ + messagePrefix?: string; +}; + +export type GoogleChatConfig = { + /** Optional per-account Google Chat configuration (multi-account). */ + accounts?: Record; +} & GoogleChatAccountConfig; + +export type GoogleChatMessage = { + name: string; + sender: { + name: string; + displayName: string; + email?: string; + type: "HUMAN" | "BOT"; + }; + createTime: string; + text?: string; + space: { + name: string; + type: "DM" | "ROOM" | "SPACE"; + displayName?: string; + }; + thread?: { + name: string; + }; + argumentText?: string; + slashCommand?: { + commandId: string; + }; +}; + +export type GoogleChatEvent = { + type: + | "MESSAGE" + | "ADDED_TO_SPACE" + | "REMOVED_FROM_SPACE" + | "CARD_CLICKED"; + eventTime: string; + message?: GoogleChatMessage; + user?: { + name: string; + displayName: string; + email?: string; + }; + space?: { + name: string; + type: string; + displayName?: string; + }; +}; diff --git a/src/providers/dock.ts b/src/providers/dock.ts index 3c3f64eb7..f6958e78c 100644 --- a/src/providers/dock.ts +++ b/src/providers/dock.ts @@ -1,5 +1,6 @@ import type { ClawdbotConfig } from "../config/config.js"; import { resolveDiscordAccount } from "../discord/accounts.js"; +import { resolveGoogleChatAccount } from "../googlechat/accounts.js"; import { resolveIMessageAccount } from "../imessage/accounts.js"; import { resolveSignalAccount } from "../signal/accounts.js"; import { resolveSlackAccount } from "../slack/accounts.js"; @@ -281,6 +282,26 @@ const DOCKS: Record = { formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom), }, }, + googlechat: { + id: "googlechat", + capabilities: { + chatTypes: ["direct", "group", "thread"], + threads: true, + media: true, + }, + outbound: { textChunkLimit: 4000 }, + config: { + resolveAllowFrom: ({ cfg, accountId }) => + resolveGoogleChatAccount({ cfg, accountId }).config.allowFrom ?? [], + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim().toLowerCase()) + .filter(Boolean), + }, + threading: { + resolveReplyToMode: () => "first", + }, + }, }; export function listProviderDocks(): ProviderDock[] { diff --git a/src/providers/plugins/googlechat.ts b/src/providers/plugins/googlechat.ts new file mode 100644 index 000000000..8f7a4efec --- /dev/null +++ b/src/providers/plugins/googlechat.ts @@ -0,0 +1,191 @@ +import { chunkMarkdownText } from "../../auto-reply/chunk.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; +import { + listGoogleChatAccountIds, + type ResolvedGoogleChatAccount, + resolveDefaultGoogleChatAccountId, + resolveGoogleChatAccount, +} from "../../googlechat/accounts.js"; +import { probeGoogleChat } from "../../googlechat/probe.js"; +import { + chunkGoogleChatText, + sendGoogleChatMedia, + sendGoogleChatText, +} from "../../googlechat/send.js"; +import { getChatProviderMeta } from "../registry.js"; +import { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "./config-helpers.js"; +import { formatPairingApproveHint } from "./helpers.js"; +import type { ProviderPlugin } from "./types.js"; + +const meta = getChatProviderMeta("googlechat"); + +export const googlechatPlugin: ProviderPlugin = { + id: "googlechat", + meta: { + ...meta, + quickstartAllowFrom: true, + }, + pairing: { + idLabel: "email", + normalizeAllowEntry: (entry) => entry.toLowerCase().trim(), + }, + capabilities: { + chatTypes: ["direct", "group", "thread"], + threads: true, + media: true, + }, + reload: { configPrefixes: ["googlechat"] }, + config: { + listAccountIds: (cfg) => listGoogleChatAccountIds(cfg), + resolveAccount: (cfg, accountId) => + resolveGoogleChatAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultGoogleChatAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "googlechat", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "googlechat", + accountId, + clearBaseFields: ["projectId", "subscriptionName", "credentialsPath", "name"], + }), + isConfigured: (account) => + Boolean(account.projectId?.trim() && account.subscriptionName?.trim()), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean( + account.projectId?.trim() && account.subscriptionName?.trim(), + ), + }), + resolveAllowFrom: ({ cfg, accountId }) => + resolveGoogleChatAccount({ cfg, accountId }).config.allowFrom ?? [], + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim().toLowerCase()) + .filter(Boolean), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const googlechat = (cfg as { googlechat?: { accounts?: Record } }).googlechat; + const resolvedAccountId = + accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean(googlechat?.accounts?.[resolvedAccountId]); + const basePath = useAccountPath + ? `googlechat.accounts.${resolvedAccountId}.` + : "googlechat."; + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("googlechat"), + normalizeEntry: (raw) => raw.toLowerCase().trim(), + }; + }, + }, + threading: { + resolveReplyToMode: () => "first", + }, + outbound: { + deliveryMode: "direct", + chunker: chunkGoogleChatText, + textChunkLimit: 4000, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error( + "Delivering to Google Chat requires --to ", + ), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ to, text, accountId, cfg, replyToId }) => { + const account = resolveGoogleChatAccount({ cfg, accountId }); + const result = await sendGoogleChatText(to, text, { + account, + threadKey: replyToId ?? undefined, + }); + return { provider: "googlechat", ...result }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId, cfg, replyToId }) => { + const account = resolveGoogleChatAccount({ cfg, accountId }); + const result = await sendGoogleChatMedia(to, mediaUrl ?? "", { + account, + caption: text, + threadKey: replyToId ?? undefined, + }); + return { provider: "googlechat", ...result }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + buildProviderSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs }) => + probeGoogleChat(account, timeoutMs), + buildAccountSnapshot: ({ account, runtime, probe }) => { + const configured = Boolean( + account.projectId?.trim() && account.subscriptionName?.trim(), + ); + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + ctx.log?.info(`[${account.accountId}] starting Google Chat provider`); + + // Lazy import to avoid circular deps + const { monitorGoogleChatProvider } = await import( + "../../googlechat/monitor.js" + ); + + return monitorGoogleChatProvider({ + account, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + }); + }, + }, +}; diff --git a/src/providers/plugins/index.ts b/src/providers/plugins/index.ts index 573248134..a6dfc8016 100644 --- a/src/providers/plugins/index.ts +++ b/src/providers/plugins/index.ts @@ -4,6 +4,7 @@ import { normalizeChatProviderId, } from "../registry.js"; import { discordPlugin } from "./discord.js"; +import { googlechatPlugin } from "./googlechat.js"; import { imessagePlugin } from "./imessage.js"; import { msteamsPlugin } from "./msteams.js"; import { signalPlugin } from "./signal.js"; @@ -31,6 +32,7 @@ function resolveProviders(): ProviderPlugin[] { signalPlugin, imessagePlugin, msteamsPlugin, + googlechatPlugin, ]; } @@ -57,6 +59,7 @@ export function normalizeProviderId(raw?: string | null): ProviderId | null { export { discordPlugin, + googlechatPlugin, imessagePlugin, msteamsPlugin, signalPlugin, diff --git a/src/providers/plugins/load.ts b/src/providers/plugins/load.ts index cb06600df..269686765 100644 --- a/src/providers/plugins/load.ts +++ b/src/providers/plugins/load.ts @@ -14,6 +14,7 @@ const LOADERS: Record = { signal: async () => (await import("./signal.js")).signalPlugin, imessage: async () => (await import("./imessage.js")).imessagePlugin, msteams: async () => (await import("./msteams.js")).msteamsPlugin, + googlechat: async () => (await import("./googlechat.js")).googlechatPlugin, }; const cache = new Map(); diff --git a/src/providers/plugins/outbound/googlechat.ts b/src/providers/plugins/outbound/googlechat.ts new file mode 100644 index 000000000..c9d9e3044 --- /dev/null +++ b/src/providers/plugins/outbound/googlechat.ts @@ -0,0 +1,40 @@ +import { resolveGoogleChatAccount } from "../../../googlechat/accounts.js"; +import { + chunkGoogleChatText, + sendGoogleChatMedia, + sendGoogleChatText, +} from "../../../googlechat/send.js"; +import type { ProviderOutboundAdapter } from "../types.js"; + +export const googlechatOutbound: ProviderOutboundAdapter = { + deliveryMode: "direct", + chunker: chunkGoogleChatText, + textChunkLimit: 4000, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error("Delivering to Google Chat requires --to "), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ to, text, accountId, cfg, replyToId }) => { + const account = resolveGoogleChatAccount({ cfg, accountId }); + const result = await sendGoogleChatText(to, text, { + account, + threadKey: replyToId ?? undefined, + }); + return { provider: "googlechat", ...result }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId, cfg, replyToId }) => { + const account = resolveGoogleChatAccount({ cfg, accountId }); + const result = await sendGoogleChatMedia(to, mediaUrl ?? "", { + account, + caption: text, + threadKey: replyToId ?? undefined, + }); + return { provider: "googlechat", ...result }; + }, +}; diff --git a/src/providers/plugins/outbound/load.ts b/src/providers/plugins/outbound/load.ts index 471c719a9..60cf88eab 100644 --- a/src/providers/plugins/outbound/load.ts +++ b/src/providers/plugins/outbound/load.ts @@ -15,6 +15,7 @@ const LOADERS: Record = { signal: async () => (await import("./signal.js")).signalOutbound, imessage: async () => (await import("./imessage.js")).imessageOutbound, msteams: async () => (await import("./msteams.js")).msteamsOutbound, + googlechat: async () => (await import("./googlechat.js")).googlechatOutbound, }; const cache = new Map(); diff --git a/src/providers/registry.ts b/src/providers/registry.ts index a35eb02f3..a0c27f4d4 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -8,6 +8,7 @@ export const CHAT_PROVIDER_ORDER = [ "signal", "imessage", "msteams", + "googlechat", ] as const; export type ChatProviderId = (typeof CHAT_PROVIDER_ORDER)[number]; @@ -94,11 +95,20 @@ const CHAT_PROVIDER_META: Record = { docsLabel: "msteams", blurb: "supported (Bot Framework).", }, + googlechat: { + id: "googlechat", + label: "Google Chat", + selectionLabel: "Google Chat (Pub/Sub)", + docsPath: "/googlechat", + docsLabel: "googlechat", + blurb: "Google Workspace only; uses Pub/Sub for firewall-friendly operation.", + }, }; export const CHAT_PROVIDER_ALIASES: Record = { imsg: "imessage", teams: "msteams", + gchat: "googlechat", }; const normalizeProviderKey = (raw?: string | null): string | undefined => {