Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
11
extensions/googlechat/README.md
Normal file
11
extensions/googlechat/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# OpenClaw Google Chat
|
||||
|
||||
Official OpenClaw channel plugin for Google Chat spaces and direct messages.
|
||||
|
||||
Install from OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugin add @openclaw/googlechat
|
||||
```
|
||||
|
||||
Configure the Google Chat app credentials and allowed spaces in OpenClaw. The plugin lets agents receive Google Chat events and reply through the configured app.
|
||||
4
extensions/googlechat/api.ts
Normal file
4
extensions/googlechat/api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Googlechat API module exposes the plugin public contract.
|
||||
export { googlechatPlugin } from "./src/channel.js";
|
||||
export { googlechatSetupAdapter } from "./src/setup-core.js";
|
||||
export { googlechatSetupWizard } from "./src/setup-surface.js";
|
||||
2
extensions/googlechat/channel-config-api.ts
Normal file
2
extensions/googlechat/channel-config-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Googlechat API module exposes the plugin public contract.
|
||||
export { GoogleChatChannelConfigSchema } from "./src/config-schema.js";
|
||||
2
extensions/googlechat/channel-plugin-api.ts
Normal file
2
extensions/googlechat/channel-plugin-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Googlechat API module exposes the plugin public contract.
|
||||
export { googlechatPlugin } from "./src/channel.js";
|
||||
3
extensions/googlechat/config-api.ts
Normal file
3
extensions/googlechat/config-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Googlechat API module exposes the plugin public contract.
|
||||
export { GoogleChatConfigSchema } from "openclaw/plugin-sdk/bundled-channel-config-schema";
|
||||
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
|
||||
7
extensions/googlechat/directory-contract-api.ts
Normal file
7
extensions/googlechat/directory-contract-api.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Googlechat API module exposes the plugin public contract.
|
||||
import { googlechatDirectoryAdapter } from "./src/channel.adapters.js";
|
||||
|
||||
export const googlechatDirectoryContractPlugin = {
|
||||
id: "googlechat",
|
||||
directory: googlechatDirectoryAdapter,
|
||||
};
|
||||
2
extensions/googlechat/doctor-contract-api.ts
Normal file
2
extensions/googlechat/doctor-contract-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Googlechat API module exposes the plugin public contract.
|
||||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
21
extensions/googlechat/index.ts
Normal file
21
extensions/googlechat/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Googlechat plugin entrypoint registers its OpenClaw integration.
|
||||
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelEntry({
|
||||
id: "googlechat",
|
||||
name: "Google Chat",
|
||||
description: "OpenClaw Google Chat channel plugin",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "googlechatPlugin",
|
||||
},
|
||||
secrets: {
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-api.js",
|
||||
exportName: "setGoogleChatRuntime",
|
||||
},
|
||||
});
|
||||
314
extensions/googlechat/npm-shrinkwrap.json
generated
Normal file
314
extensions/googlechat/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,314 @@
|
||||
{
|
||||
"name": "@openclaw/googlechat",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/googlechat",
|
||||
"version": "2026.6.11",
|
||||
"dependencies": {
|
||||
"gaxios": "7.1.5",
|
||||
"google-auth-library": "10.9.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"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/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "^1.0.0",
|
||||
"web-streams-polyfill": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gaxios": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz",
|
||||
"integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"node-fetch": "^3.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/gcp-metadata": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
|
||||
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"gaxios": "^7.0.0",
|
||||
"google-logging-utils": "^1.0.0",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/google-auth-library": {
|
||||
"version": "10.9.0",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz",
|
||||
"integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^7.1.4",
|
||||
"gcp-metadata": "8.1.2",
|
||||
"google-logging-utils": "1.1.3",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/google-logging-utils": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
|
||||
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/json-bigint": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"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/node-domexception": {
|
||||
"name": "@nolyfill/domexception",
|
||||
"version": "1.0.28",
|
||||
"resolved": "https://registry.npmjs.org/@nolyfill/domexception/-/domexception-1.0.28.tgz",
|
||||
"integrity": "sha512-tlc/FcYIv5i8RYsl2iDil4A0gOihaas1R5jPcIC4Zw3GhjKsVilw90aHcVlhZPTBLGBzd379S+VcnsDjd9ChiA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
extensions/googlechat/openclaw.plugin.json
Normal file
18
extensions/googlechat/openclaw.plugin.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "googlechat",
|
||||
"name": "Google Chat",
|
||||
"description": "OpenClaw Google Chat channel plugin for spaces and direct messages.",
|
||||
"icon": "https://cdn.simpleicons.org/googlechat",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"channels": ["googlechat"],
|
||||
"channelEnvVars": {
|
||||
"googlechat": ["GOOGLE_CHAT_SERVICE_ACCOUNT", "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
88
extensions/googlechat/package.json
Normal file
88
extensions/googlechat/package.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"name": "@openclaw/googlechat",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Google Chat channel plugin for spaces and direct messages.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"gaxios": "7.1.5",
|
||||
"google-auth-library": "10.9.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "googlechat",
|
||||
"label": "Google Chat",
|
||||
"selectionLabel": "Google Chat (Chat API)",
|
||||
"detailLabel": "Google Chat",
|
||||
"docsPath": "/channels/googlechat",
|
||||
"docsLabel": "googlechat",
|
||||
"blurb": "Google Workspace Chat app with HTTP webhook.",
|
||||
"aliases": [
|
||||
"gchat",
|
||||
"google-chat"
|
||||
],
|
||||
"order": 55,
|
||||
"systemImage": "message.badge",
|
||||
"markdownCapable": true,
|
||||
"doctorCapabilities": {
|
||||
"dmAllowFromMode": "nestedOnly",
|
||||
"groupModel": "route",
|
||||
"groupAllowFromFallbackToAllowFrom": false,
|
||||
"warnOnEmptyGroupSenderAllowlist": false
|
||||
},
|
||||
"cliAddOptions": [
|
||||
{
|
||||
"flags": "--webhook-path <path>",
|
||||
"description": "Google Chat webhook path"
|
||||
},
|
||||
{
|
||||
"flags": "--webhook-url <url>",
|
||||
"description": "Google Chat webhook URL"
|
||||
},
|
||||
{
|
||||
"flags": "--audience-type <type>",
|
||||
"description": "Google Chat audience type (app-url|project-number)"
|
||||
},
|
||||
{
|
||||
"flags": "--audience <value>",
|
||||
"description": "Google Chat audience value (app URL or project number)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/googlechat",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
61
extensions/googlechat/runtime-api.ts
Normal file
61
extensions/googlechat/runtime-api.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Private runtime barrel for the bundled Google Chat extension.
|
||||
// Keep this barrel thin and avoid broad plugin-sdk surfaces during bootstrap.
|
||||
|
||||
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
export {
|
||||
createActionGate,
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk/channel-actions";
|
||||
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
|
||||
export type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
ChannelStatusIssue,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
export { missingTargetError } from "openclaw/plugin-sdk/channel-feedback";
|
||||
export {
|
||||
createAccountStatusSink,
|
||||
runPassiveAccountLifecycle,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
|
||||
export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
export { GoogleChatConfigSchema } from "openclaw/plugin-sdk/bundled-channel-config-schema";
|
||||
export {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
export {
|
||||
readRemoteMediaBuffer,
|
||||
resolveChannelMediaMaxBytes,
|
||||
} from "openclaw/plugin-sdk/media-runtime";
|
||||
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
|
||||
export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
export { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
export type {
|
||||
GoogleChatAccountConfig,
|
||||
GoogleChatConfig,
|
||||
} from "openclaw/plugin-sdk/config-contracts";
|
||||
export { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||
export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
|
||||
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
||||
export { resolveWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
export {
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
resolveWebhookTargetWithAuthOrReject,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
} from "openclaw/plugin-sdk/webhook-targets";
|
||||
export {
|
||||
createWebhookInFlightLimiter,
|
||||
readJsonWebhookBodyOrReject,
|
||||
type WebhookInFlightLimiter,
|
||||
} from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
export { setGoogleChatRuntime } from "./src/runtime.js";
|
||||
6
extensions/googlechat/secret-contract-api.ts
Normal file
6
extensions/googlechat/secret-contract-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Googlechat API module exposes the plugin public contract.
|
||||
export {
|
||||
channelSecrets,
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
14
extensions/googlechat/setup-entry.ts
Normal file
14
extensions/googlechat/setup-entry.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Googlechat plugin module implements setup entry behavior.
|
||||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./setup-plugin-api.js",
|
||||
exportName: "googlechatSetupPlugin",
|
||||
},
|
||||
secrets: {
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
});
|
||||
3
extensions/googlechat/setup-plugin-api.ts
Normal file
3
extensions/googlechat/setup-plugin-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Keep bundled setup entry imports narrow so setup loads do not pull the
|
||||
// broader Google Chat runtime plugin surface.
|
||||
export { googlechatSetupPlugin } from "./src/channel.setup.js";
|
||||
185
extensions/googlechat/src/accounts.ts
Normal file
185
extensions/googlechat/src/accounts.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
// Googlechat plugin module implements accounts behavior.
|
||||
import {
|
||||
createAccountListHelpers,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
type OpenClawConfig,
|
||||
resolveAccountEntry,
|
||||
resolveMergedAccountConfig,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime";
|
||||
import { isSecretRef } from "openclaw/plugin-sdk/secret-input";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { z } from "zod";
|
||||
import type { GoogleChatAccountConfig } from "./types.config.js";
|
||||
|
||||
type GoogleChatCredentialSource = "file" | "inline" | "env" | "none";
|
||||
|
||||
export type ResolvedGoogleChatAccount = {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
config: GoogleChatAccountConfig;
|
||||
credentialSource: GoogleChatCredentialSource;
|
||||
credentials?: Record<string, unknown>;
|
||||
credentialsFile?: string;
|
||||
};
|
||||
|
||||
export type GoogleChatConfigAccessorAccount = {
|
||||
config: GoogleChatAccountConfig;
|
||||
};
|
||||
|
||||
const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
|
||||
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
|
||||
const JsonRecordSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
const {
|
||||
listAccountIds: listGoogleChatAccountIds,
|
||||
resolveDefaultAccountId: resolveDefaultGoogleChatAccountId,
|
||||
} = createAccountListHelpers("googlechat", {
|
||||
implicitDefaultAccount: {
|
||||
channelKeys: ["serviceAccount", "serviceAccountRef", "serviceAccountFile"],
|
||||
envVars: [ENV_SERVICE_ACCOUNT, ENV_SERVICE_ACCOUNT_FILE],
|
||||
},
|
||||
});
|
||||
export { listGoogleChatAccountIds, resolveDefaultGoogleChatAccountId };
|
||||
|
||||
function mergeGoogleChatAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): GoogleChatAccountConfig {
|
||||
const raw = cfg.channels?.["googlechat"] ?? {};
|
||||
const base = resolveMergedAccountConfig<GoogleChatAccountConfig>({
|
||||
channelConfig: raw as GoogleChatAccountConfig,
|
||||
accounts: raw.accounts as Record<string, Partial<GoogleChatAccountConfig>> | undefined,
|
||||
accountId,
|
||||
omitKeys: ["defaultAccount"],
|
||||
nestedObjectKeys: ["botLoopProtection"],
|
||||
});
|
||||
const defaultAccountConfig = resolveAccountEntry(raw.accounts, DEFAULT_ACCOUNT_ID) ?? {};
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return base;
|
||||
}
|
||||
const {
|
||||
enabled: _ignoredEnabled,
|
||||
dangerouslyAllowNameMatching: _ignoredDangerouslyAllowNameMatching,
|
||||
serviceAccount: _ignoredServiceAccount,
|
||||
serviceAccountRef: _ignoredServiceAccountRef,
|
||||
serviceAccountFile: _ignoredServiceAccountFile,
|
||||
...defaultAccountShared
|
||||
} = defaultAccountConfig;
|
||||
// In multi-account setups, allow accounts.default to provide shared defaults
|
||||
// (for example webhook/audience fields) while preserving top-level and account overrides.
|
||||
const botLoopProtection = mergePairLoopGuardConfig(
|
||||
defaultAccountShared.botLoopProtection,
|
||||
base.botLoopProtection,
|
||||
);
|
||||
return {
|
||||
...defaultAccountShared,
|
||||
...base,
|
||||
...(botLoopProtection ? { botLoopProtection } : {}),
|
||||
} as GoogleChatAccountConfig;
|
||||
}
|
||||
|
||||
export function resolveGoogleChatConfigAccessorAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): GoogleChatConfigAccessorAccount {
|
||||
const accountId = normalizeAccountId(
|
||||
params.accountId ?? params.cfg.channels?.googlechat?.defaultAccount,
|
||||
);
|
||||
return { config: mergeGoogleChatAccountConfig(params.cfg, accountId) };
|
||||
}
|
||||
|
||||
function parseServiceAccount(value: unknown): Record<string, unknown> | null {
|
||||
if (isSecretRef(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return safeParseJsonWithSchema(JsonRecordSchema, trimmed);
|
||||
}
|
||||
|
||||
return safeParseWithSchema(JsonRecordSchema, value);
|
||||
}
|
||||
|
||||
function resolveCredentialsFromConfig(params: {
|
||||
accountId: string;
|
||||
account: GoogleChatAccountConfig;
|
||||
}): {
|
||||
credentials?: Record<string, unknown>;
|
||||
credentialsFile?: string;
|
||||
source: GoogleChatCredentialSource;
|
||||
} {
|
||||
const { account, accountId } = params;
|
||||
const inline = parseServiceAccount(account.serviceAccount);
|
||||
if (inline) {
|
||||
return { credentials: inline, source: "inline" };
|
||||
}
|
||||
|
||||
if (isSecretRef(account.serviceAccount)) {
|
||||
throw new Error(
|
||||
`channels.googlechat.accounts.${accountId}.serviceAccount: unresolved SecretRef "${account.serviceAccount.source}:${account.serviceAccount.provider}:${account.serviceAccount.id}". Resolve this command against an active gateway runtime snapshot before reading it.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (isSecretRef(account.serviceAccountRef)) {
|
||||
throw new Error(
|
||||
`channels.googlechat.accounts.${accountId}.serviceAccount: unresolved SecretRef "${account.serviceAccountRef.source}:${account.serviceAccountRef.provider}:${account.serviceAccountRef.id}". Resolve this command against an active gateway runtime snapshot before reading it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const file = normalizeOptionalString(account.serviceAccountFile);
|
||||
if (file) {
|
||||
return { credentialsFile: file, source: "file" };
|
||||
}
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
const envJson = process.env[ENV_SERVICE_ACCOUNT];
|
||||
const envInline = parseServiceAccount(envJson);
|
||||
if (envInline) {
|
||||
return { credentials: envInline, source: "env" };
|
||||
}
|
||||
const envFile = normalizeOptionalString(process.env[ENV_SERVICE_ACCOUNT_FILE]);
|
||||
if (envFile) {
|
||||
return { credentialsFile: envFile, source: "env" };
|
||||
}
|
||||
}
|
||||
|
||||
return { source: "none" };
|
||||
}
|
||||
|
||||
export function resolveGoogleChatAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedGoogleChatAccount {
|
||||
const accountId = normalizeAccountId(
|
||||
params.accountId ?? params.cfg.channels?.["googlechat"]?.defaultAccount,
|
||||
);
|
||||
const baseEnabled = params.cfg.channels?.["googlechat"]?.enabled !== false;
|
||||
const merged = mergeGoogleChatAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const credentials = resolveCredentialsFromConfig({ accountId, account: merged });
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: normalizeOptionalString(merged.name),
|
||||
enabled,
|
||||
config: merged,
|
||||
credentialSource: credentials.source,
|
||||
credentials: credentials.credentials,
|
||||
credentialsFile: credentials.credentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledGoogleChatAccounts(cfg: OpenClawConfig): ResolvedGoogleChatAccount[] {
|
||||
return listGoogleChatAccountIds(cfg)
|
||||
.map((accountId) => resolveGoogleChatAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
324
extensions/googlechat/src/actions.test.ts
Normal file
324
extensions/googlechat/src/actions.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
// Googlechat tests cover actions plugin behavior.
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listEnabledGoogleChatAccounts = vi.hoisted(() => vi.fn());
|
||||
const resolveGoogleChatAccount = vi.hoisted(() => vi.fn());
|
||||
const createGoogleChatReaction = vi.hoisted(() => vi.fn());
|
||||
const deleteGoogleChatReaction = vi.hoisted(() => vi.fn());
|
||||
const listGoogleChatReactions = vi.hoisted(() => vi.fn());
|
||||
const sendGoogleChatMessage = vi.hoisted(() => vi.fn());
|
||||
const uploadGoogleChatAttachment = vi.hoisted(() => vi.fn());
|
||||
const resolveGoogleChatOutboundSpace = vi.hoisted(() => vi.fn());
|
||||
const getGoogleChatRuntime = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
listEnabledGoogleChatAccounts,
|
||||
resolveGoogleChatAccount,
|
||||
}));
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
createGoogleChatReaction,
|
||||
deleteGoogleChatReaction,
|
||||
listGoogleChatReactions,
|
||||
sendGoogleChatMessage,
|
||||
uploadGoogleChatAttachment,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getGoogleChatRuntime,
|
||||
}));
|
||||
|
||||
vi.mock("./targets.js", () => ({
|
||||
resolveGoogleChatOutboundSpace,
|
||||
}));
|
||||
|
||||
let googlechatMessageActions: typeof import("./actions.js").googlechatMessageActions;
|
||||
|
||||
describe("googlechat message actions", () => {
|
||||
beforeAll(async () => {
|
||||
({ googlechatMessageActions } = await import("./actions.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./accounts.js");
|
||||
vi.doUnmock("./api.js");
|
||||
vi.doUnmock("./runtime.js");
|
||||
vi.doUnmock("./targets.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
function buildAccount(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "service-account",
|
||||
config: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function expectJsonResult(result: unknown, details: Record<string, unknown>) {
|
||||
expect(result).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(details, null, 2),
|
||||
},
|
||||
],
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
it("describes send and reaction actions only when enabled accounts exist", () => {
|
||||
listEnabledGoogleChatAccounts.mockReturnValueOnce([]);
|
||||
expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toBeNull();
|
||||
|
||||
listEnabledGoogleChatAccounts.mockReturnValueOnce([
|
||||
{
|
||||
enabled: true,
|
||||
credentialSource: "service-account",
|
||||
config: { actions: { reactions: true } },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toEqual({
|
||||
actions: ["send", "upload-file", "react", "reactions"],
|
||||
});
|
||||
});
|
||||
|
||||
it("honors account-scoped reaction gates during discovery", () => {
|
||||
resolveGoogleChatAccount.mockImplementation(({ accountId }: { accountId?: string | null }) => ({
|
||||
enabled: true,
|
||||
credentialSource: "service-account",
|
||||
config: {
|
||||
actions: { reactions: accountId === "work" },
|
||||
},
|
||||
}));
|
||||
|
||||
expect(
|
||||
googlechatMessageActions.describeMessageTool?.({ cfg: {} as never, accountId: "default" }),
|
||||
).toEqual({
|
||||
actions: ["send", "upload-file"],
|
||||
});
|
||||
expect(
|
||||
googlechatMessageActions.describeMessageTool?.({ cfg: {} as never, accountId: "work" }),
|
||||
).toEqual({
|
||||
actions: ["send", "upload-file", "react", "reactions"],
|
||||
});
|
||||
});
|
||||
|
||||
it("sends messages with uploaded media through the resolved space", async () => {
|
||||
const account = buildAccount({
|
||||
config: { mediaMaxMb: 5 },
|
||||
});
|
||||
resolveGoogleChatAccount.mockReturnValue(account);
|
||||
resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/AAA");
|
||||
const readRemoteMediaBuffer = vi.fn(async () => ({
|
||||
buffer: Buffer.from("remote-bytes"),
|
||||
fileName: "remote.png",
|
||||
contentType: "image/png",
|
||||
}));
|
||||
getGoogleChatRuntime.mockReturnValue({
|
||||
channel: {
|
||||
media: {
|
||||
readRemoteMediaBuffer,
|
||||
},
|
||||
},
|
||||
});
|
||||
uploadGoogleChatAttachment.mockResolvedValue({
|
||||
attachmentUploadToken: "token-1",
|
||||
});
|
||||
sendGoogleChatMessage.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
threadName: "spaces/AAA/threads/thread-1",
|
||||
});
|
||||
|
||||
if (!googlechatMessageActions.handleAction) {
|
||||
throw new Error("Expected googlechatMessageActions.handleAction to be defined");
|
||||
}
|
||||
const result = await googlechatMessageActions.handleAction({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "spaces/AAA",
|
||||
message: "caption",
|
||||
media: "https://example.com/file.png",
|
||||
threadId: "thread-1",
|
||||
},
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
} as never);
|
||||
|
||||
expect(resolveGoogleChatOutboundSpace).toHaveBeenCalledWith({
|
||||
account,
|
||||
target: "spaces/AAA",
|
||||
});
|
||||
expect(readRemoteMediaBuffer).toHaveBeenCalledWith({
|
||||
url: "https://example.com/file.png",
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
});
|
||||
expect(uploadGoogleChatAttachment).toHaveBeenCalledWith({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
filename: "remote.png",
|
||||
buffer: Buffer.from("remote-bytes"),
|
||||
contentType: "image/png",
|
||||
});
|
||||
expect(sendGoogleChatMessage).toHaveBeenCalledWith({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "caption",
|
||||
thread: "thread-1",
|
||||
attachments: [{ attachmentUploadToken: "token-1", contentName: "remote.png" }],
|
||||
});
|
||||
expectJsonResult(result, {
|
||||
ok: true,
|
||||
to: "spaces/AAA",
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
threadName: "spaces/AAA/threads/thread-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes upload-file through the same attachment upload path with filename override", async () => {
|
||||
const account = buildAccount({
|
||||
config: { mediaMaxMb: 5 },
|
||||
});
|
||||
resolveGoogleChatAccount.mockReturnValue(account);
|
||||
resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/BBB");
|
||||
const localRoot = "/tmp/googlechat-action-test";
|
||||
const localPath = path.join(localRoot, "local.md");
|
||||
const readFile = vi.fn(async () => Buffer.from("local-bytes"));
|
||||
getGoogleChatRuntime.mockReturnValue({
|
||||
channel: {
|
||||
media: {
|
||||
readRemoteMediaBuffer: vi.fn(),
|
||||
},
|
||||
},
|
||||
});
|
||||
uploadGoogleChatAttachment.mockResolvedValue({
|
||||
attachmentUploadToken: "token-2",
|
||||
});
|
||||
sendGoogleChatMessage.mockResolvedValue({
|
||||
messageName: "spaces/BBB/messages/msg-2",
|
||||
threadName: "spaces/BBB/threads/thread-2",
|
||||
});
|
||||
|
||||
if (!googlechatMessageActions.handleAction) {
|
||||
throw new Error("Expected googlechatMessageActions.handleAction to be defined");
|
||||
}
|
||||
const result = await googlechatMessageActions.handleAction({
|
||||
action: "upload-file",
|
||||
params: {
|
||||
to: "spaces/BBB",
|
||||
path: localPath,
|
||||
message: "notes",
|
||||
filename: "renamed.txt",
|
||||
},
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
mediaLocalRoots: [localRoot],
|
||||
mediaReadFile: readFile,
|
||||
} as never);
|
||||
|
||||
expect(readFile).toHaveBeenCalledWith(localPath);
|
||||
expect(uploadGoogleChatAttachment).toHaveBeenCalledWith({
|
||||
account,
|
||||
space: "spaces/BBB",
|
||||
filename: "renamed.txt",
|
||||
buffer: Buffer.from("local-bytes"),
|
||||
contentType: "text/markdown",
|
||||
});
|
||||
expect(sendGoogleChatMessage).toHaveBeenCalledWith({
|
||||
account,
|
||||
space: "spaces/BBB",
|
||||
text: "notes",
|
||||
thread: undefined,
|
||||
attachments: [{ attachmentUploadToken: "token-2", contentName: "renamed.txt" }],
|
||||
});
|
||||
expectJsonResult(result, {
|
||||
ok: true,
|
||||
to: "spaces/BBB",
|
||||
messageName: "spaces/BBB/messages/msg-2",
|
||||
threadName: "spaces/BBB/threads/thread-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes only matching app reactions on react remove", async () => {
|
||||
const account = buildAccount({
|
||||
config: { botUser: "users/app-bot" },
|
||||
});
|
||||
resolveGoogleChatAccount.mockReturnValue(account);
|
||||
listGoogleChatReactions.mockResolvedValue([
|
||||
{
|
||||
name: "reactions/1",
|
||||
emoji: { unicode: "👍" },
|
||||
user: { name: "users/app" },
|
||||
},
|
||||
{
|
||||
name: "reactions/2",
|
||||
emoji: { unicode: "👍" },
|
||||
user: { name: "users/app-bot" },
|
||||
},
|
||||
{
|
||||
name: "reactions/3",
|
||||
emoji: { unicode: "👍" },
|
||||
user: { name: "users/other" },
|
||||
},
|
||||
]);
|
||||
|
||||
if (!googlechatMessageActions.handleAction) {
|
||||
throw new Error("Expected googlechatMessageActions.handleAction to be defined");
|
||||
}
|
||||
const result = await googlechatMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
messageId: "spaces/AAA/messages/msg-1",
|
||||
emoji: "👍",
|
||||
remove: true,
|
||||
},
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
} as never);
|
||||
|
||||
expect(listGoogleChatReactions).toHaveBeenCalledWith({
|
||||
account,
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
});
|
||||
expect(deleteGoogleChatReaction).toHaveBeenCalledTimes(2);
|
||||
expect(deleteGoogleChatReaction).toHaveBeenNthCalledWith(1, {
|
||||
account,
|
||||
reactionName: "reactions/1",
|
||||
});
|
||||
expect(deleteGoogleChatReaction).toHaveBeenNthCalledWith(2, {
|
||||
account,
|
||||
reactionName: "reactions/2",
|
||||
});
|
||||
expectJsonResult(result, { ok: true, removed: 2 });
|
||||
});
|
||||
|
||||
it("rejects fractional reaction limits before listing reactions", async () => {
|
||||
const account = buildAccount();
|
||||
resolveGoogleChatAccount.mockReturnValue(account);
|
||||
|
||||
if (!googlechatMessageActions.handleAction) {
|
||||
throw new Error("Expected googlechatMessageActions.handleAction to be defined");
|
||||
}
|
||||
await expect(
|
||||
googlechatMessageActions.handleAction({
|
||||
action: "reactions",
|
||||
params: {
|
||||
messageId: "spaces/AAA/messages/msg-1",
|
||||
limit: 2.5,
|
||||
},
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
} as never),
|
||||
).rejects.toThrow("limit must be a positive integer");
|
||||
|
||||
expect(listGoogleChatReactions).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
228
extensions/googlechat/src/actions.ts
Normal file
228
extensions/googlechat/src/actions.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
// Googlechat plugin module implements actions behavior.
|
||||
import {
|
||||
createActionGate,
|
||||
jsonResult,
|
||||
readPositiveIntegerParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk/channel-actions";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
|
||||
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||
import { listEnabledGoogleChatAccounts, resolveGoogleChatAccount } from "./accounts.js";
|
||||
import {
|
||||
createGoogleChatReaction,
|
||||
deleteGoogleChatReaction,
|
||||
listGoogleChatReactions,
|
||||
sendGoogleChatMessage,
|
||||
uploadGoogleChatAttachment,
|
||||
} from "./api.js";
|
||||
import { getGoogleChatRuntime } from "./runtime.js";
|
||||
import { resolveGoogleChatOutboundSpace } from "./targets.js";
|
||||
|
||||
const providerId = "googlechat";
|
||||
|
||||
function listEnabledAccounts(cfg: OpenClawConfig) {
|
||||
return listEnabledGoogleChatAccounts(cfg).filter(
|
||||
(account) => account.enabled && account.credentialSource !== "none",
|
||||
);
|
||||
}
|
||||
|
||||
function isReactionsEnabled(accounts: Array<{ config: { actions?: unknown } }>) {
|
||||
for (const account of accounts) {
|
||||
const gate = createActionGate(account.config.actions as Record<string, boolean | undefined>);
|
||||
if (gate("reactions")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveAppUserNames(account: { config: { botUser?: string | null } }) {
|
||||
return new Set(["users/app", account.config.botUser?.trim()].filter(Boolean) as string[]);
|
||||
}
|
||||
|
||||
async function loadGoogleChatActionMedia(params: {
|
||||
mediaUrl: string;
|
||||
maxBytes: number;
|
||||
mediaAccess?: {
|
||||
localRoots?: readonly string[];
|
||||
readFile?: (filePath: string) => Promise<Buffer>;
|
||||
};
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
}) {
|
||||
const runtime = getGoogleChatRuntime();
|
||||
return /^https?:\/\//i.test(params.mediaUrl)
|
||||
? await runtime.channel.media.readRemoteMediaBuffer({
|
||||
url: params.mediaUrl,
|
||||
maxBytes: params.maxBytes,
|
||||
})
|
||||
: await loadOutboundMediaFromUrl(params.mediaUrl, {
|
||||
maxBytes: params.maxBytes,
|
||||
mediaAccess: params.mediaAccess,
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
mediaReadFile: params.mediaReadFile,
|
||||
});
|
||||
}
|
||||
|
||||
export const googlechatMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: ({ cfg, accountId }) => {
|
||||
const accounts = accountId
|
||||
? [resolveGoogleChatAccount({ cfg, accountId })].filter(
|
||||
(account) => account.enabled && account.credentialSource !== "none",
|
||||
)
|
||||
: listEnabledAccounts(cfg);
|
||||
if (accounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const actions = new Set<ChannelMessageActionName>([]);
|
||||
actions.add("send");
|
||||
actions.add("upload-file");
|
||||
if (isReactionsEnabled(accounts)) {
|
||||
actions.add("react");
|
||||
actions.add("reactions");
|
||||
}
|
||||
return { actions: Array.from(actions) };
|
||||
},
|
||||
extractToolSend: ({ args }) => {
|
||||
return extractToolSend(args, "sendMessage");
|
||||
},
|
||||
handleAction: async ({
|
||||
action,
|
||||
params,
|
||||
cfg,
|
||||
accountId,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
}) => {
|
||||
const account = resolveGoogleChatAccount({
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
if (account.credentialSource === "none") {
|
||||
throw new Error("Google Chat credentials are missing.");
|
||||
}
|
||||
|
||||
if (action === "send" || action === "upload-file") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content =
|
||||
readStringParam(params, "message", {
|
||||
required: action === "send",
|
||||
allowEmpty: true,
|
||||
}) ??
|
||||
readStringParam(params, "initialComment", {
|
||||
allowEmpty: true,
|
||||
}) ??
|
||||
"";
|
||||
const mediaUrl =
|
||||
readStringParam(params, "media", { trim: false }) ??
|
||||
readStringParam(params, "filePath", { trim: false }) ??
|
||||
readStringParam(params, "path", { trim: false });
|
||||
const threadId = readStringParam(params, "threadId") ?? readStringParam(params, "replyTo");
|
||||
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
|
||||
|
||||
if (mediaUrl) {
|
||||
const maxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||
const loaded = await loadGoogleChatActionMedia({
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
});
|
||||
const uploadFileName =
|
||||
readStringParam(params, "filename") ??
|
||||
readStringParam(params, "title") ??
|
||||
loaded.fileName ??
|
||||
"attachment";
|
||||
const upload = await uploadGoogleChatAttachment({
|
||||
account,
|
||||
space,
|
||||
filename: uploadFileName,
|
||||
buffer: loaded.buffer,
|
||||
contentType: loaded.contentType,
|
||||
});
|
||||
const sent = await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text: content,
|
||||
thread: threadId ?? undefined,
|
||||
attachments: upload.attachmentUploadToken
|
||||
? [
|
||||
{
|
||||
attachmentUploadToken: upload.attachmentUploadToken,
|
||||
contentName: uploadFileName,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, to: space, ...sent });
|
||||
}
|
||||
|
||||
if (action === "upload-file") {
|
||||
throw new Error("upload-file requires media, filePath, or path");
|
||||
}
|
||||
|
||||
const sent = await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text: content,
|
||||
thread: threadId ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, to: space, ...sent });
|
||||
}
|
||||
|
||||
if (action === "react") {
|
||||
const messageName = readStringParam(params, "messageId", { required: true });
|
||||
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
||||
removeErrorMessage: "Emoji is required to remove a Google Chat reaction.",
|
||||
});
|
||||
if (remove || isEmpty) {
|
||||
const reactions = await listGoogleChatReactions({ account, messageName });
|
||||
const appUsers = resolveAppUserNames(account);
|
||||
const toRemove = reactions.filter((reaction) => {
|
||||
const userName = reaction.user?.name?.trim();
|
||||
if (appUsers.size > 0 && !appUsers.has(userName ?? "")) {
|
||||
return false;
|
||||
}
|
||||
if (emoji) {
|
||||
return reaction.emoji?.unicode === emoji;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
for (const reaction of toRemove) {
|
||||
if (!reaction.name) {
|
||||
continue;
|
||||
}
|
||||
await deleteGoogleChatReaction({ account, reactionName: reaction.name });
|
||||
}
|
||||
return jsonResult({ ok: true, removed: toRemove.length });
|
||||
}
|
||||
const reaction = await createGoogleChatReaction({
|
||||
account,
|
||||
messageName,
|
||||
emoji,
|
||||
});
|
||||
return jsonResult({ ok: true, reaction });
|
||||
}
|
||||
|
||||
if (action === "reactions") {
|
||||
const messageName = readStringParam(params, "messageId", { required: true });
|
||||
const limit = readPositiveIntegerParam(params, "limit");
|
||||
const reactions = await listGoogleChatReactions({
|
||||
account,
|
||||
messageName,
|
||||
limit: limit ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, reactions });
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||
},
|
||||
};
|
||||
346
extensions/googlechat/src/api.ts
Normal file
346
extensions/googlechat/src/api.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
// Googlechat API module exposes the plugin public contract.
|
||||
import crypto from "node:crypto";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { shouldSuppressGoogleChatManualExecApprovalFollowupText } from "./approval-card-actions.js";
|
||||
import { getGoogleChatAccessToken } from "./auth.js";
|
||||
import type { GoogleChatCardV2, GoogleChatReaction } from "./types.js";
|
||||
|
||||
const CHAT_API_BASE = "https://chat.googleapis.com/v1";
|
||||
const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1";
|
||||
|
||||
async function readGoogleChatJsonResponse<T>(response: Response, label: string): Promise<T> {
|
||||
return readProviderJsonResponse<T>(response, label);
|
||||
}
|
||||
|
||||
const headersToObject = (headers?: HeadersInit): Record<string, string> =>
|
||||
headers instanceof Headers
|
||||
? Object.fromEntries(headers.entries())
|
||||
: Array.isArray(headers)
|
||||
? Object.fromEntries(headers)
|
||||
: headers || {};
|
||||
|
||||
async function withGoogleChatResponse<T>(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
url: string;
|
||||
init?: RequestInit;
|
||||
auditContext: string;
|
||||
errorPrefix?: string;
|
||||
handleResponse: (response: Response) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
const {
|
||||
account,
|
||||
url,
|
||||
init,
|
||||
auditContext,
|
||||
errorPrefix = "Google Chat API",
|
||||
handleResponse,
|
||||
} = params;
|
||||
const token = await getGoogleChatAccessToken(account);
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: {
|
||||
...init,
|
||||
headers: {
|
||||
...headersToObject(init?.headers),
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
auditContext,
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const text = await readResponseTextLimited(response).catch(() => "");
|
||||
throw new Error(`${errorPrefix} ${response.status}: ${text || response.statusText}`);
|
||||
}
|
||||
return await handleResponse(response);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson<T>(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
): Promise<T> {
|
||||
return await withGoogleChatResponse({
|
||||
account,
|
||||
url,
|
||||
init: {
|
||||
...init,
|
||||
headers: {
|
||||
...headersToObject(init.headers),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
auditContext: "googlechat.api.json",
|
||||
handleResponse: async (response) =>
|
||||
await readGoogleChatJsonResponse<T>(response, "Google Chat API request failed"),
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchOk(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
): Promise<void> {
|
||||
await withGoogleChatResponse({
|
||||
account,
|
||||
url,
|
||||
init,
|
||||
auditContext: "googlechat.api.ok",
|
||||
handleResponse: async () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchBuffer(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
options?: { maxBytes?: number },
|
||||
): Promise<{ buffer: Buffer; contentType?: string }> {
|
||||
return await withGoogleChatResponse({
|
||||
account,
|
||||
url,
|
||||
init,
|
||||
auditContext: "googlechat.api.buffer",
|
||||
handleResponse: async (res) => {
|
||||
const maxBytes = options?.maxBytes;
|
||||
const lengthHeader = res.headers.get("content-length");
|
||||
if (maxBytes && lengthHeader) {
|
||||
const length = parseMediaContentLength(lengthHeader);
|
||||
if (length !== null && length > maxBytes) {
|
||||
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
||||
}
|
||||
}
|
||||
if (!maxBytes) {
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const contentType = res.headers.get("content-type") ?? undefined;
|
||||
return { buffer, contentType };
|
||||
}
|
||||
const buffer = await readResponseWithLimit(res, maxBytes, {
|
||||
onOverflow: () => new Error(`Google Chat media exceeds max bytes (${maxBytes})`),
|
||||
});
|
||||
const contentType = res.headers.get("content-type") ?? undefined;
|
||||
return { buffer, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendGoogleChatMessage(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
space: string;
|
||||
text?: string;
|
||||
thread?: string;
|
||||
cardsV2?: GoogleChatCardV2[];
|
||||
attachments?: Array<{ attachmentUploadToken: string; contentName?: string }>;
|
||||
}): Promise<{ messageName?: string; threadName?: string } | null> {
|
||||
const { account, space, text, thread, cardsV2, attachments } = params;
|
||||
if (
|
||||
text &&
|
||||
(!cardsV2 || cardsV2.length === 0) &&
|
||||
(!attachments || attachments.length === 0) &&
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupText(text)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const body: Record<string, unknown> = {};
|
||||
if (text) {
|
||||
body.text = text;
|
||||
}
|
||||
if (cardsV2 && cardsV2.length > 0) {
|
||||
body.cardsV2 = cardsV2;
|
||||
}
|
||||
if (thread) {
|
||||
body.thread = { name: thread };
|
||||
}
|
||||
if (attachments && attachments.length > 0) {
|
||||
body.attachment = attachments.map((item) =>
|
||||
Object.assign(
|
||||
{ attachmentDataRef: { attachmentUploadToken: item.attachmentUploadToken } },
|
||||
item.contentName ? { contentName: item.contentName } : {},
|
||||
),
|
||||
);
|
||||
}
|
||||
const urlObj = new URL(`${CHAT_API_BASE}/${space}/messages`);
|
||||
if (thread) {
|
||||
urlObj.searchParams.set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD");
|
||||
}
|
||||
const url = urlObj.toString();
|
||||
const result = await fetchJson<{ name?: string; thread?: { name?: string } }>(account, url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return result ? { messageName: result.name, threadName: result.thread?.name } : null;
|
||||
}
|
||||
|
||||
export async function updateGoogleChatMessage(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
messageName: string;
|
||||
text?: string;
|
||||
cardsV2?: GoogleChatCardV2[];
|
||||
}): Promise<{ messageName?: string }> {
|
||||
const { account, messageName, text, cardsV2 } = params;
|
||||
const updateMask = [
|
||||
...(text !== undefined ? ["text"] : []),
|
||||
...(cardsV2 !== undefined ? ["cardsV2"] : []),
|
||||
];
|
||||
if (updateMask.length === 0) {
|
||||
throw new Error("Google Chat message update requires text or cardsV2.");
|
||||
}
|
||||
const url = `${CHAT_API_BASE}/${messageName}?updateMask=${updateMask.join(",")}`;
|
||||
const body: Record<string, unknown> = {};
|
||||
if (text !== undefined) {
|
||||
body.text = text;
|
||||
}
|
||||
if (cardsV2 !== undefined) {
|
||||
body.cardsV2 = cardsV2;
|
||||
}
|
||||
const result = await fetchJson<{ name?: string }>(account, url, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { messageName: result.name };
|
||||
}
|
||||
|
||||
export async function deleteGoogleChatMessage(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
messageName: string;
|
||||
}): Promise<void> {
|
||||
const { account, messageName } = params;
|
||||
const url = `${CHAT_API_BASE}/${messageName}`;
|
||||
await fetchOk(account, url, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function uploadGoogleChatAttachment(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
space: string;
|
||||
filename: string;
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
}): Promise<{ attachmentUploadToken?: string }> {
|
||||
const { account, space, filename, buffer, contentType } = params;
|
||||
const boundary = `openclaw-${crypto.randomUUID()}`;
|
||||
const metadata = JSON.stringify({ filename });
|
||||
const header = `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n`;
|
||||
const mediaHeader = `--${boundary}\r\nContent-Type: ${contentType ?? "application/octet-stream"}\r\n\r\n`;
|
||||
const footer = `\r\n--${boundary}--\r\n`;
|
||||
const body = Buffer.concat([
|
||||
Buffer.from(header, "utf8"),
|
||||
Buffer.from(mediaHeader, "utf8"),
|
||||
buffer,
|
||||
Buffer.from(footer, "utf8"),
|
||||
]);
|
||||
|
||||
const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`;
|
||||
const payload = await withGoogleChatResponse<{
|
||||
attachmentDataRef?: { attachmentUploadToken?: string };
|
||||
}>({
|
||||
account,
|
||||
url,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": `multipart/related; boundary=${boundary}`,
|
||||
},
|
||||
body,
|
||||
},
|
||||
auditContext: "googlechat.upload",
|
||||
errorPrefix: "Google Chat upload",
|
||||
handleResponse: async (response) =>
|
||||
await readGoogleChatJsonResponse<{
|
||||
attachmentDataRef?: { attachmentUploadToken?: string };
|
||||
}>(response, "Google Chat upload failed"),
|
||||
});
|
||||
return {
|
||||
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadGoogleChatMedia(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
resourceName: string;
|
||||
maxBytes?: number;
|
||||
}): Promise<{ buffer: Buffer; contentType?: string }> {
|
||||
const { account, resourceName, maxBytes } = params;
|
||||
const url = `${CHAT_API_BASE}/media/${resourceName}?alt=media`;
|
||||
return await fetchBuffer(account, url, undefined, { maxBytes });
|
||||
}
|
||||
|
||||
export async function createGoogleChatReaction(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
messageName: string;
|
||||
emoji: string;
|
||||
}): Promise<GoogleChatReaction> {
|
||||
const { account, messageName, emoji } = params;
|
||||
const url = `${CHAT_API_BASE}/${messageName}/reactions`;
|
||||
return await fetchJson<GoogleChatReaction>(account, url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ emoji: { unicode: emoji } }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listGoogleChatReactions(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
messageName: string;
|
||||
limit?: number;
|
||||
}): Promise<GoogleChatReaction[]> {
|
||||
const { account, messageName, limit } = params;
|
||||
const url = new URL(`${CHAT_API_BASE}/${messageName}/reactions`);
|
||||
if (limit && limit > 0) {
|
||||
url.searchParams.set("pageSize", String(limit));
|
||||
}
|
||||
const result = await fetchJson<{ reactions?: GoogleChatReaction[] }>(account, url.toString(), {
|
||||
method: "GET",
|
||||
});
|
||||
return result.reactions ?? [];
|
||||
}
|
||||
|
||||
export async function deleteGoogleChatReaction(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
reactionName: string;
|
||||
}): Promise<void> {
|
||||
const { account, reactionName } = params;
|
||||
const url = `${CHAT_API_BASE}/${reactionName}`;
|
||||
await fetchOk(account, url, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function findGoogleChatDirectMessage(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
userName: string;
|
||||
}): Promise<{ name?: string; displayName?: string } | null> {
|
||||
const { account, userName } = params;
|
||||
const url = new URL(`${CHAT_API_BASE}/spaces:findDirectMessage`);
|
||||
url.searchParams.set("name", userName);
|
||||
return await fetchJson<{ name?: string; displayName?: string }>(account, url.toString(), {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
export async function probeGoogleChat(account: ResolvedGoogleChatAccount): Promise<{
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const url = new URL(`${CHAT_API_BASE}/spaces`);
|
||||
url.searchParams.set("pageSize", "1");
|
||||
await fetchJson<Record<string, unknown>>(account, url.toString(), {
|
||||
method: "GET",
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: formatErrorMessage(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
25
extensions/googlechat/src/approval-auth.test.ts
Normal file
25
extensions/googlechat/src/approval-auth.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Googlechat tests cover approval auth plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { googleChatApprovalAuth } from "./approval-auth.js";
|
||||
|
||||
describe("googleChatApprovalAuth", () => {
|
||||
it("authorizes stable users/* ids and ignores email-style approvers", () => {
|
||||
expect(
|
||||
googleChatApprovalAuth.authorizeActorAction({
|
||||
cfg: { channels: { googlechat: { dm: { allowFrom: ["users/123"] } } } },
|
||||
senderId: "users/123",
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
|
||||
expect(
|
||||
googleChatApprovalAuth.authorizeActorAction({
|
||||
cfg: { channels: { googlechat: { dm: { allowFrom: ["owner@example.com"] } } } },
|
||||
senderId: "users/attacker",
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
});
|
||||
});
|
||||
38
extensions/googlechat/src/approval-auth.ts
Normal file
38
extensions/googlechat/src/approval-auth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Googlechat plugin module implements approval auth behavior.
|
||||
import {
|
||||
createResolvedApproverActionAuthAdapter,
|
||||
resolveApprovalApprovers,
|
||||
} from "openclaw/plugin-sdk/approval-auth-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveGoogleChatAccount } from "./accounts.js";
|
||||
import { isGoogleChatUserTarget, normalizeGoogleChatTarget } from "./targets.js";
|
||||
|
||||
export function normalizeGoogleChatApproverId(value: string | number): string | undefined {
|
||||
const normalized = normalizeGoogleChatTarget(String(value));
|
||||
if (!normalized || !isGoogleChatUserTarget(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
const suffix = normalizeLowercaseStringOrEmpty(normalized.slice("users/".length));
|
||||
if (!suffix || suffix.includes("@")) {
|
||||
return undefined;
|
||||
}
|
||||
return `users/${suffix}`;
|
||||
}
|
||||
|
||||
export function getGoogleChatApprovalApprovers(params: {
|
||||
cfg: Parameters<typeof resolveGoogleChatAccount>[0]["cfg"];
|
||||
accountId?: string | null;
|
||||
}): string[] {
|
||||
const account = resolveGoogleChatAccount(params).config;
|
||||
return resolveApprovalApprovers({
|
||||
allowFrom: account.dm?.allowFrom,
|
||||
defaultTo: account.defaultTo,
|
||||
normalizeApprover: normalizeGoogleChatApproverId,
|
||||
});
|
||||
}
|
||||
|
||||
export const googleChatApprovalAuth = createResolvedApproverActionAuthAdapter({
|
||||
channelLabel: "Google Chat",
|
||||
resolveApprovers: getGoogleChatApprovalApprovers,
|
||||
normalizeSenderId: (value) => normalizeGoogleChatApproverId(value),
|
||||
});
|
||||
113
extensions/googlechat/src/approval-card-actions.test.ts
Normal file
113
extensions/googlechat/src/approval-card-actions.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearGoogleChatApprovalCardBindingsForTest,
|
||||
registerGoogleChatManualApprovalFollowupSuppression,
|
||||
registerGoogleChatApprovalCardBinding,
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupPayload,
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupText,
|
||||
} from "./approval-card-actions.js";
|
||||
|
||||
const approvalId = "12345678-1234-1234-1234-123456789012";
|
||||
type TestExecApprovalDecision = "allow-once" | "allow-always" | "deny";
|
||||
let tokenCounter = 0;
|
||||
|
||||
function registerExecApprovalCard(overrides?: {
|
||||
approvalId?: string;
|
||||
expiresAtMs?: number;
|
||||
allowedDecisions?: readonly TestExecApprovalDecision[];
|
||||
}): void {
|
||||
registerGoogleChatApprovalCardBinding({
|
||||
token: `token-${tokenCounter++}`,
|
||||
accountId: "default",
|
||||
approvalId: overrides?.approvalId ?? approvalId,
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
allowedDecisions: overrides?.allowedDecisions ?? ["allow-once", "deny"],
|
||||
spaceName: "spaces/AAA",
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
expiresAtMs: overrides?.expiresAtMs ?? Date.now() + 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Google Chat approval card action registry", () => {
|
||||
beforeEach(() => {
|
||||
clearGoogleChatApprovalCardBindingsForTest();
|
||||
tokenCounter = 0;
|
||||
});
|
||||
|
||||
it("suppresses manual exec approval follow-up text for an active native card", () => {
|
||||
registerExecApprovalCard();
|
||||
|
||||
expect(
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupText(
|
||||
`I need approval.\nReply with:\n/approve ${approvalId.slice(0, 8)} allow-once`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupText(
|
||||
`Run this if needed: \`/approve ${approvalId} deny\``,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses manual exec approval follow-up text after native delivery before token binding", () => {
|
||||
registerGoogleChatManualApprovalFollowupSuppression({
|
||||
approvalId,
|
||||
approvalKind: "exec",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupText(
|
||||
`Please reply with:\n/approve ${approvalId.slice(0, 8)} allow-once`,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps unrelated, expired, and non-sendable approval text visible", () => {
|
||||
registerExecApprovalCard({ expiresAtMs: Date.now() - 1 });
|
||||
expect(
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupText(
|
||||
`/approve ${approvalId.slice(0, 8)} allow-once`,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
clearGoogleChatApprovalCardBindingsForTest();
|
||||
registerExecApprovalCard();
|
||||
expect(
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupText("/approve deadbeef allow-once"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupText(`/approve ${approvalId} nope`),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses only text-only manual approval follow-up payloads", () => {
|
||||
registerExecApprovalCard();
|
||||
|
||||
expect(
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupPayload({
|
||||
text: `/approve ${approvalId.slice(0, 8)} allow-once`,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupPayload({
|
||||
text: `/approve ${approvalId.slice(0, 8)} allow-once`,
|
||||
mediaUrl: "https://example.test/image.png",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupPayload({
|
||||
text: `/approve ${approvalId.slice(0, 8)} allow-once`,
|
||||
channelData: { execApproval: { approvalId } },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupPayload({
|
||||
text: `/approve ${approvalId.slice(0, 8)} allow-once`,
|
||||
presentation: { blocks: [] },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
307
extensions/googlechat/src/approval-card-actions.ts
Normal file
307
extensions/googlechat/src/approval-card-actions.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { ExecApprovalDecision } from "openclaw/plugin-sdk/approval-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { GoogleChatActionParameter, GoogleChatEvent } from "./types.js";
|
||||
|
||||
export const GOOGLECHAT_APPROVAL_ACTION = "openclaw.approval";
|
||||
const GOOGLECHAT_APPROVAL_ACTION_PARAM = "openclaw_action";
|
||||
const GOOGLECHAT_APPROVAL_TOKEN_PARAM = "token";
|
||||
const GOOGLECHAT_APPROVAL_ACTION_VALUE = "approval";
|
||||
const MANUAL_EXEC_APPROVAL_COMMAND_RE =
|
||||
/(?:^|[\s`])\/approve[ \t]+([^ \t\r\n`|]+)[ \t]+(allow-once|allow-always|deny)(?=$|[\s`|.,;:!?])/giu;
|
||||
|
||||
export type GoogleChatApprovalCardBinding = {
|
||||
token: string;
|
||||
accountId: string;
|
||||
approvalId: string;
|
||||
approvalKind: "exec" | "plugin";
|
||||
decision: ExecApprovalDecision;
|
||||
allowedDecisions: readonly ExecApprovalDecision[];
|
||||
spaceName: string;
|
||||
messageName: string;
|
||||
threadName?: string | null;
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
const approvalCardBindings = new Map<string, GoogleChatApprovalCardBinding>();
|
||||
const approvalCardResolvingTokens = new Set<string>();
|
||||
|
||||
type GoogleChatManualApprovalSuppressionPayload = {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
presentation?: unknown;
|
||||
interactive?: unknown;
|
||||
channelData?: unknown;
|
||||
btw?: unknown;
|
||||
spokenText?: unknown;
|
||||
ttsSupplement?: unknown;
|
||||
};
|
||||
|
||||
type GoogleChatManualApprovalFollowupSuppression = {
|
||||
approvalId: string;
|
||||
approvalKind: "exec" | "plugin";
|
||||
allowedDecisions: readonly ExecApprovalDecision[];
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
export type GoogleChatApprovalCardClaim =
|
||||
| { kind: "claimed"; binding: GoogleChatApprovalCardBinding }
|
||||
| { kind: "missing" }
|
||||
| { kind: "in-flight" };
|
||||
|
||||
const manualApprovalFollowupSuppressions = new Map<
|
||||
string,
|
||||
GoogleChatManualApprovalFollowupSuppression
|
||||
>();
|
||||
|
||||
export function createGoogleChatApprovalToken(): string {
|
||||
return crypto.randomBytes(18).toString("base64url");
|
||||
}
|
||||
|
||||
export function buildGoogleChatApprovalActionParameters(
|
||||
token: string,
|
||||
): GoogleChatActionParameter[] {
|
||||
return [
|
||||
{ key: GOOGLECHAT_APPROVAL_ACTION_PARAM, value: GOOGLECHAT_APPROVAL_ACTION_VALUE },
|
||||
{ key: GOOGLECHAT_APPROVAL_TOKEN_PARAM, value: token },
|
||||
];
|
||||
}
|
||||
|
||||
function collectEventParameters(event: GoogleChatEvent): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(event.common?.parameters ?? {})) {
|
||||
if (typeof value === "string") {
|
||||
params[key] = value;
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(event.commonEventObject?.parameters ?? {})) {
|
||||
if (typeof value === "string") {
|
||||
params[key] = value;
|
||||
}
|
||||
}
|
||||
for (const item of event.action?.parameters ?? []) {
|
||||
if (typeof item.key === "string" && typeof item.value === "string") {
|
||||
params[item.key] = item.value;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export function readGoogleChatApprovalActionToken(event: GoogleChatEvent): string | null {
|
||||
const params = collectEventParameters(event);
|
||||
if (params[GOOGLECHAT_APPROVAL_ACTION_PARAM] !== GOOGLECHAT_APPROVAL_ACTION_VALUE) {
|
||||
return null;
|
||||
}
|
||||
const actionName =
|
||||
normalizeOptionalString(event.action?.actionMethodName) ??
|
||||
normalizeOptionalString(event.common?.invokedFunction) ??
|
||||
normalizeOptionalString(event.commonEventObject?.invokedFunction);
|
||||
if (
|
||||
actionName &&
|
||||
actionName !== GOOGLECHAT_APPROVAL_ACTION &&
|
||||
!actionName.startsWith("https://")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return normalizeOptionalString(params[GOOGLECHAT_APPROVAL_TOKEN_PARAM]) ?? null;
|
||||
}
|
||||
|
||||
export function registerGoogleChatApprovalCardBinding(
|
||||
binding: GoogleChatApprovalCardBinding,
|
||||
): boolean {
|
||||
if (binding.expiresAtMs <= Date.now()) {
|
||||
return false;
|
||||
}
|
||||
approvalCardBindings.set(binding.token, binding);
|
||||
registerGoogleChatManualApprovalFollowupSuppression({
|
||||
approvalId: binding.approvalId,
|
||||
approvalKind: binding.approvalKind,
|
||||
allowedDecisions: binding.allowedDecisions,
|
||||
expiresAtMs: binding.expiresAtMs,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getGoogleChatApprovalCardBinding(
|
||||
token: string,
|
||||
): GoogleChatApprovalCardBinding | null {
|
||||
const binding = approvalCardBindings.get(token);
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
if (binding.expiresAtMs <= Date.now()) {
|
||||
approvalCardBindings.delete(token);
|
||||
return null;
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
function normalizeApprovalRef(value: string): string | null {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function manualApprovalFollowupSuppressionKey(approvalId: string): string | null {
|
||||
return normalizeApprovalRef(approvalId);
|
||||
}
|
||||
|
||||
export function registerGoogleChatManualApprovalFollowupSuppression(
|
||||
suppression: GoogleChatManualApprovalFollowupSuppression,
|
||||
): boolean {
|
||||
if (suppression.expiresAtMs <= Date.now()) {
|
||||
return false;
|
||||
}
|
||||
const key = manualApprovalFollowupSuppressionKey(suppression.approvalId);
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
manualApprovalFollowupSuppressions.set(key, suppression);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function unregisterGoogleChatManualApprovalFollowupSuppression(approvalId: string): void {
|
||||
const key = manualApprovalFollowupSuppressionKey(approvalId);
|
||||
if (key) {
|
||||
manualApprovalFollowupSuppressions.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function approvalRefMatches(bindingApprovalId: string, approvalRef: string): boolean {
|
||||
const normalizedBindingId = normalizeApprovalRef(bindingApprovalId);
|
||||
const normalizedRef = normalizeApprovalRef(approvalRef);
|
||||
if (!normalizedBindingId || !normalizedRef) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalizedRef === normalizedBindingId ||
|
||||
(normalizedRef.length >= 8 && normalizedBindingId.startsWith(normalizedRef))
|
||||
);
|
||||
}
|
||||
|
||||
function pruneExpiredGoogleChatApprovalCardBindings(nowMs: number): void {
|
||||
for (const [token, binding] of approvalCardBindings) {
|
||||
if (binding.expiresAtMs <= nowMs) {
|
||||
approvalCardBindings.delete(token);
|
||||
approvalCardResolvingTokens.delete(token);
|
||||
}
|
||||
}
|
||||
for (const [approvalId, suppression] of manualApprovalFollowupSuppressions) {
|
||||
if (suppression.expiresAtMs <= nowMs) {
|
||||
manualApprovalFollowupSuppressions.delete(approvalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasActiveGoogleChatExecApprovalCardForManualCommand(params: {
|
||||
approvalRef: string;
|
||||
decision: ExecApprovalDecision;
|
||||
nowMs: number;
|
||||
}): boolean {
|
||||
pruneExpiredGoogleChatApprovalCardBindings(params.nowMs);
|
||||
for (const binding of approvalCardBindings.values()) {
|
||||
if (
|
||||
binding.approvalKind === "exec" &&
|
||||
binding.allowedDecisions.includes(params.decision) &&
|
||||
approvalRefMatches(binding.approvalId, params.approvalRef)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const suppression of manualApprovalFollowupSuppressions.values()) {
|
||||
if (
|
||||
suppression.approvalKind === "exec" &&
|
||||
suppression.allowedDecisions.includes(params.decision) &&
|
||||
approvalRefMatches(suppression.approvalId, params.approvalRef)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldSuppressGoogleChatManualExecApprovalFollowupText(
|
||||
text: string,
|
||||
nowMs = Date.now(),
|
||||
): boolean {
|
||||
for (const match of text.matchAll(MANUAL_EXEC_APPROVAL_COMMAND_RE)) {
|
||||
const approvalRef = match[1];
|
||||
const decision = match[2]?.toLowerCase() as ExecApprovalDecision | undefined;
|
||||
if (
|
||||
approvalRef &&
|
||||
decision &&
|
||||
hasActiveGoogleChatExecApprovalCardForManualCommand({ approvalRef, decision, nowMs })
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasSendableMedia(payload: GoogleChatManualApprovalSuppressionPayload): boolean {
|
||||
return Boolean(payload.mediaUrl?.trim() || payload.mediaUrls?.some((url) => url.trim()));
|
||||
}
|
||||
|
||||
function hasStructuredPayloadPart(payload: GoogleChatManualApprovalSuppressionPayload): boolean {
|
||||
return Boolean(
|
||||
hasSendableMedia(payload) ||
|
||||
payload.presentation ||
|
||||
payload.interactive ||
|
||||
payload.btw ||
|
||||
payload.spokenText ||
|
||||
payload.ttsSupplement,
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldSuppressGoogleChatManualExecApprovalFollowupPayload(
|
||||
payload: GoogleChatManualApprovalSuppressionPayload,
|
||||
nowMs = Date.now(),
|
||||
): boolean {
|
||||
const text = payload.text?.trim();
|
||||
if (!text || hasStructuredPayloadPart(payload)) {
|
||||
return false;
|
||||
}
|
||||
return shouldSuppressGoogleChatManualExecApprovalFollowupText(text, nowMs);
|
||||
}
|
||||
|
||||
export function claimGoogleChatApprovalCardBinding(token: string): GoogleChatApprovalCardClaim {
|
||||
const binding = getGoogleChatApprovalCardBinding(token);
|
||||
if (!binding) {
|
||||
return { kind: "missing" };
|
||||
}
|
||||
if (approvalCardResolvingTokens.has(token)) {
|
||||
return { kind: "in-flight" };
|
||||
}
|
||||
approvalCardResolvingTokens.add(token);
|
||||
return { kind: "claimed", binding };
|
||||
}
|
||||
|
||||
export function completeGoogleChatApprovalCardBinding(token: string): void {
|
||||
const binding = approvalCardBindings.get(token);
|
||||
approvalCardResolvingTokens.delete(token);
|
||||
approvalCardBindings.delete(token);
|
||||
if (binding) {
|
||||
unregisterGoogleChatManualApprovalFollowupSuppression(binding.approvalId);
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseGoogleChatApprovalCardBinding(token: string): void {
|
||||
approvalCardResolvingTokens.delete(token);
|
||||
}
|
||||
|
||||
export function unregisterGoogleChatApprovalCardBindings(tokens: readonly string[]): void {
|
||||
for (const token of tokens) {
|
||||
const binding = approvalCardBindings.get(token);
|
||||
approvalCardBindings.delete(token);
|
||||
approvalCardResolvingTokens.delete(token);
|
||||
if (binding) {
|
||||
unregisterGoogleChatManualApprovalFollowupSuppression(binding.approvalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGoogleChatApprovalCardBindingsForTest(): void {
|
||||
approvalCardBindings.clear();
|
||||
approvalCardResolvingTokens.clear();
|
||||
manualApprovalFollowupSuppressions.clear();
|
||||
}
|
||||
279
extensions/googlechat/src/approval-card-click.test.ts
Normal file
279
extensions/googlechat/src/approval-card-click.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildGoogleChatApprovalActionParameters,
|
||||
clearGoogleChatApprovalCardBindingsForTest,
|
||||
registerGoogleChatApprovalCardBinding,
|
||||
} from "./approval-card-actions.js";
|
||||
import { maybeHandleGoogleChatApprovalCardClick } from "./approval-card-click.js";
|
||||
import type { WebhookTarget } from "./monitor-types.js";
|
||||
import type { GoogleChatEvent } from "./types.js";
|
||||
|
||||
const resolveApprovalOverGateway = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({
|
||||
resolveApprovalOverGateway,
|
||||
}));
|
||||
|
||||
function createTarget(): WebhookTarget {
|
||||
return {
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "inline",
|
||||
config: {
|
||||
dm: { allowFrom: ["users/123"] },
|
||||
},
|
||||
},
|
||||
config: {
|
||||
channels: {
|
||||
googlechat: {
|
||||
dm: { allowFrom: ["users/123"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core: {} as never,
|
||||
path: "/googlechat",
|
||||
mediaMaxMb: 20,
|
||||
};
|
||||
}
|
||||
|
||||
function createCardClickEvent(token: string, userName = "users/123"): GoogleChatEvent {
|
||||
return {
|
||||
type: "CARD_CLICKED",
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { name: "spaces/AAA/messages/msg-1" },
|
||||
user: { name: userName },
|
||||
action: {
|
||||
actionMethodName: "openclaw.approval",
|
||||
parameters: buildGoogleChatApprovalActionParameters(token),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("maybeHandleGoogleChatApprovalCardClick", () => {
|
||||
beforeEach(() => {
|
||||
clearGoogleChatApprovalCardBindingsForTest();
|
||||
resolveApprovalOverGateway.mockReset();
|
||||
});
|
||||
|
||||
it("authorizes the Chat actor and resolves the bound approval over the gateway", async () => {
|
||||
registerGoogleChatApprovalCardBinding({
|
||||
token: "token-1",
|
||||
accountId: "default",
|
||||
approvalId: "approval-1",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
spaceName: "spaces/AAA",
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
maybeHandleGoogleChatApprovalCardClick({
|
||||
event: createCardClickEvent("token-1"),
|
||||
target: createTarget(),
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(resolveApprovalOverGateway).toHaveBeenCalledWith({
|
||||
cfg: expect.any(Object),
|
||||
approvalId: "approval-1",
|
||||
decision: "allow-once",
|
||||
senderId: "users/123",
|
||||
allowPluginFallback: true,
|
||||
clientDisplayName: "Google Chat approval (users/123)",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts add-on clicks that only carry approval token parameters", async () => {
|
||||
registerGoogleChatApprovalCardBinding({
|
||||
token: "token-addon",
|
||||
accountId: "default",
|
||||
approvalId: "approval-addon",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
spaceName: "spaces/AAA",
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
maybeHandleGoogleChatApprovalCardClick({
|
||||
event: {
|
||||
type: "CARD_CLICKED",
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { name: "spaces/AAA/messages/msg-1" },
|
||||
user: { name: "users/123" },
|
||||
commonEventObject: {
|
||||
parameters: {
|
||||
openclaw_action: "approval",
|
||||
token: "token-addon",
|
||||
},
|
||||
},
|
||||
},
|
||||
target: createTarget(),
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(resolveApprovalOverGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
approvalId: "approval-addon",
|
||||
decision: "allow-once",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts standard cardsV2 clicks with common parameters", async () => {
|
||||
registerGoogleChatApprovalCardBinding({
|
||||
token: "token-common",
|
||||
accountId: "default",
|
||||
approvalId: "approval-common",
|
||||
approvalKind: "plugin",
|
||||
decision: "deny",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
spaceName: "spaces/AAA",
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
maybeHandleGoogleChatApprovalCardClick({
|
||||
event: {
|
||||
type: "CARD_CLICKED",
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { name: "spaces/AAA/messages/msg-1" },
|
||||
user: { name: "users/123" },
|
||||
common: {
|
||||
invokedFunction: "openclaw.approval",
|
||||
parameters: {
|
||||
openclaw_action: "approval",
|
||||
token: "token-common",
|
||||
},
|
||||
},
|
||||
},
|
||||
target: createTarget(),
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(resolveApprovalOverGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
approvalId: "approval-common",
|
||||
decision: "deny",
|
||||
allowPluginFallback: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts endpoint URL invoked functions for app-url card actions", async () => {
|
||||
registerGoogleChatApprovalCardBinding({
|
||||
token: "token-url",
|
||||
accountId: "default",
|
||||
approvalId: "approval-url",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
spaceName: "spaces/AAA",
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
maybeHandleGoogleChatApprovalCardClick({
|
||||
event: {
|
||||
type: "CARD_CLICKED",
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { name: "spaces/AAA/messages/msg-1" },
|
||||
user: { name: "users/123" },
|
||||
commonEventObject: {
|
||||
invokedFunction: "https://chat-app.example.test/googlechat",
|
||||
parameters: {
|
||||
openclaw_action: "approval",
|
||||
token: "token-url",
|
||||
},
|
||||
},
|
||||
},
|
||||
target: createTarget(),
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(resolveApprovalOverGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
approvalId: "approval-url",
|
||||
decision: "allow-once",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not consume the token when an unauthorized user clicks", async () => {
|
||||
registerGoogleChatApprovalCardBinding({
|
||||
token: "token-2",
|
||||
accountId: "default",
|
||||
approvalId: "plugin:approval-2",
|
||||
approvalKind: "plugin",
|
||||
decision: "deny",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
spaceName: "spaces/AAA",
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
maybeHandleGoogleChatApprovalCardClick({
|
||||
event: createCardClickEvent("token-2", "users/999"),
|
||||
target: createTarget(),
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(resolveApprovalOverGateway).not.toHaveBeenCalled();
|
||||
|
||||
await expect(
|
||||
maybeHandleGoogleChatApprovalCardClick({
|
||||
event: createCardClickEvent("token-2", "users/123"),
|
||||
target: createTarget(),
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(resolveApprovalOverGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
approvalId: "plugin:approval-2",
|
||||
decision: "deny",
|
||||
allowPluginFallback: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the token retryable when gateway resolution fails", async () => {
|
||||
registerGoogleChatApprovalCardBinding({
|
||||
token: "token-retry",
|
||||
accountId: "default",
|
||||
approvalId: "approval-retry",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
spaceName: "spaces/AAA",
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
resolveApprovalOverGateway.mockRejectedValueOnce(new Error("gateway unavailable"));
|
||||
|
||||
await expect(
|
||||
maybeHandleGoogleChatApprovalCardClick({
|
||||
event: createCardClickEvent("token-retry"),
|
||||
target: createTarget(),
|
||||
}),
|
||||
).rejects.toThrow("gateway unavailable");
|
||||
|
||||
resolveApprovalOverGateway.mockResolvedValueOnce(undefined);
|
||||
await expect(
|
||||
maybeHandleGoogleChatApprovalCardClick({
|
||||
event: createCardClickEvent("token-retry"),
|
||||
target: createTarget(),
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(resolveApprovalOverGateway).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
94
extensions/googlechat/src/approval-card-click.ts
Normal file
94
extensions/googlechat/src/approval-card-click.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";
|
||||
import { googleChatApprovalAuth } from "./approval-auth.js";
|
||||
import {
|
||||
claimGoogleChatApprovalCardBinding,
|
||||
completeGoogleChatApprovalCardBinding,
|
||||
getGoogleChatApprovalCardBinding,
|
||||
releaseGoogleChatApprovalCardBinding,
|
||||
readGoogleChatApprovalActionToken,
|
||||
} from "./approval-card-actions.js";
|
||||
import type { WebhookTarget } from "./monitor-types.js";
|
||||
import type { GoogleChatEvent } from "./types.js";
|
||||
|
||||
function logIgnored(target: WebhookTarget, message: string): void {
|
||||
target.runtime.log?.(`[${target.account.accountId}] googlechat approval ignored: ${message}`);
|
||||
}
|
||||
|
||||
export async function maybeHandleGoogleChatApprovalCardClick(params: {
|
||||
event: GoogleChatEvent;
|
||||
target: WebhookTarget;
|
||||
}): Promise<boolean> {
|
||||
const eventType = params.event.type ?? params.event.eventType;
|
||||
if (eventType !== "CARD_CLICKED") {
|
||||
return false;
|
||||
}
|
||||
const token = readGoogleChatApprovalActionToken(params.event);
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const binding = getGoogleChatApprovalCardBinding(token);
|
||||
if (!binding) {
|
||||
logIgnored(params.target, "unknown or expired card token");
|
||||
return true;
|
||||
}
|
||||
if (binding.accountId !== params.target.account.accountId) {
|
||||
logIgnored(params.target, "card token account mismatch");
|
||||
return true;
|
||||
}
|
||||
if (params.event.space?.name !== binding.spaceName) {
|
||||
logIgnored(params.target, "card token space mismatch");
|
||||
return true;
|
||||
}
|
||||
if (params.event.message?.name && params.event.message.name !== binding.messageName) {
|
||||
logIgnored(params.target, "card token message mismatch");
|
||||
return true;
|
||||
}
|
||||
if (!binding.allowedDecisions.includes(binding.decision)) {
|
||||
logIgnored(params.target, "card token decision is no longer allowed");
|
||||
return true;
|
||||
}
|
||||
|
||||
const actor = params.event.user?.name;
|
||||
const auth = googleChatApprovalAuth.authorizeActorAction?.({
|
||||
cfg: params.target.config,
|
||||
accountId: params.target.account.accountId,
|
||||
senderId: actor,
|
||||
action: "approve",
|
||||
approvalKind: binding.approvalKind,
|
||||
});
|
||||
if (!auth?.authorized) {
|
||||
logIgnored(params.target, `unauthorized actor ${actor || "unknown"}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const claim = claimGoogleChatApprovalCardBinding(token);
|
||||
if (claim.kind === "missing") {
|
||||
logIgnored(params.target, "card token already consumed");
|
||||
return true;
|
||||
}
|
||||
if (claim.kind === "in-flight") {
|
||||
logIgnored(params.target, "card token resolve already in flight");
|
||||
return true;
|
||||
}
|
||||
const consumed = claim.binding;
|
||||
|
||||
try {
|
||||
await resolveApprovalOverGateway({
|
||||
cfg: params.target.config,
|
||||
approvalId: consumed.approvalId,
|
||||
decision: consumed.decision,
|
||||
senderId: actor,
|
||||
allowPluginFallback: consumed.approvalKind === "exec",
|
||||
clientDisplayName: `Google Chat approval (${actor?.trim() || "unknown"})`,
|
||||
});
|
||||
} catch (error) {
|
||||
releaseGoogleChatApprovalCardBinding(token);
|
||||
throw error;
|
||||
}
|
||||
completeGoogleChatApprovalCardBinding(token);
|
||||
params.target.runtime.log?.(
|
||||
`[${params.target.account.accountId}] googlechat approval resolved id=${consumed.approvalId} decision=${consumed.decision} sender=${actor || "unknown"}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
452
extensions/googlechat/src/approval-handler.runtime.test.ts
Normal file
452
extensions/googlechat/src/approval-handler.runtime.test.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import type {
|
||||
ExecApprovalPendingView,
|
||||
ResolvedApprovalView,
|
||||
} from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import {
|
||||
clearGoogleChatApprovalCardBindingsForTest,
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupText,
|
||||
} from "./approval-card-actions.js";
|
||||
|
||||
const sendGoogleChatMessage = vi.hoisted(() => vi.fn());
|
||||
const updateGoogleChatMessage = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./api.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./api.js")>("./api.js");
|
||||
return {
|
||||
...actual,
|
||||
sendGoogleChatMessage,
|
||||
updateGoogleChatMessage,
|
||||
};
|
||||
});
|
||||
|
||||
const { googleChatApprovalNativeRuntime } = await import("./approval-handler.runtime.js");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearGoogleChatApprovalCardBindingsForTest();
|
||||
});
|
||||
|
||||
const account = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "inline",
|
||||
config: {
|
||||
audienceType: "app-url",
|
||||
audience: "https://chat-app.example.test/googlechat",
|
||||
appPrincipal: "123456789012345678901",
|
||||
},
|
||||
} as ResolvedGoogleChatAccount;
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
client_email: "bot@example.com",
|
||||
private_key: "test-key",
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
audienceType: "app-url",
|
||||
audience: "https://chat-app.example.test/googlechat",
|
||||
appPrincipal: "123456789012345678901",
|
||||
dm: { allowFrom: ["users/123"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function createPendingView(): ExecApprovalPendingView {
|
||||
return {
|
||||
approvalId: "approval-1",
|
||||
approvalKind: "exec",
|
||||
phase: "pending",
|
||||
title: "Exec Approval Required",
|
||||
description: "A command needs your approval.",
|
||||
metadata: [{ label: "Agent", value: "main" }],
|
||||
ask: "on-miss",
|
||||
agentId: "main",
|
||||
warningText: null,
|
||||
commandAnalysis: null,
|
||||
commandText: "echo hi",
|
||||
commandPreview: null,
|
||||
cwd: "/tmp",
|
||||
envKeys: [],
|
||||
host: "gateway",
|
||||
nodeId: null,
|
||||
sessionKey: "agent:main:googlechat:spaces/AAA",
|
||||
actions: [
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
style: "success",
|
||||
command: "/approve approval-1 allow-once",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
command: "/approve approval-1 deny",
|
||||
},
|
||||
],
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
reject: (reason?: unknown) => void;
|
||||
resolve: (value: T) => void;
|
||||
} {
|
||||
let resolve: (value: T) => void = () => {};
|
||||
let reject: (reason?: unknown) => void = () => {};
|
||||
const promise = new Promise<T>((innerResolve, innerReject) => {
|
||||
resolve = innerResolve;
|
||||
reject = innerReject;
|
||||
});
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
type CardPayloadWithTextWidgets = {
|
||||
cardsV2: Array<{
|
||||
card: {
|
||||
sections?: Array<{
|
||||
header?: string;
|
||||
widgets?: Array<{ textParagraph?: { text: string } }>;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
function getTextParagraphText(payload: unknown, header: string): string {
|
||||
const text = (payload as CardPayloadWithTextWidgets).cardsV2[0]?.card.sections?.find(
|
||||
(section) => section.header === header,
|
||||
)?.widgets?.[0]?.textParagraph?.text;
|
||||
if (typeof text !== "string") {
|
||||
throw new Error(`Expected ${header} text paragraph`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function isUtf16WellFormed(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const codeUnit = value.charCodeAt(index);
|
||||
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
||||
const nextCodeUnit = index + 1 < value.length ? value.charCodeAt(index + 1) : -1;
|
||||
if (nextCodeUnit < 0xdc00 || nextCodeUnit > 0xdfff) {
|
||||
return false;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
describe("googleChatApprovalNativeRuntime", () => {
|
||||
async function preparePendingDelivery(view = createPendingView()) {
|
||||
const nowMs = Date.now();
|
||||
const request = {
|
||||
id: view.approvalId,
|
||||
request: { command: view.commandText },
|
||||
createdAtMs: nowMs,
|
||||
expiresAtMs: view.expiresAtMs,
|
||||
};
|
||||
const pendingPayload = await googleChatApprovalNativeRuntime.presentation.buildPendingPayload({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
context: { account },
|
||||
request,
|
||||
approvalKind: "exec",
|
||||
nowMs,
|
||||
view,
|
||||
});
|
||||
const plannedTarget = {
|
||||
surface: "origin" as const,
|
||||
target: { to: "spaces/AAA", threadId: "threads/T1" },
|
||||
reason: "preferred" as const,
|
||||
};
|
||||
const prepared = await googleChatApprovalNativeRuntime.transport.prepareTarget({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
context: { account },
|
||||
plannedTarget,
|
||||
request,
|
||||
approvalKind: "exec",
|
||||
view,
|
||||
pendingPayload,
|
||||
});
|
||||
if (!prepared) {
|
||||
throw new Error("Expected prepared target");
|
||||
}
|
||||
return { pendingPayload, plannedTarget, prepared, request, view };
|
||||
}
|
||||
|
||||
it("keeps truncated pending command card text UTF-16 well formed", async () => {
|
||||
const view = createPendingView();
|
||||
view.commandText = `${"a".repeat(1796)}😀${"b".repeat(100)}`;
|
||||
|
||||
const { pendingPayload } = await preparePendingDelivery(view);
|
||||
const commandText = getTextParagraphText(pendingPayload, "Command");
|
||||
|
||||
expect(commandText.length).toBeLessThanOrEqual(1800);
|
||||
expect(commandText.endsWith("...")).toBe(true);
|
||||
expect(isUtf16WellFormed(commandText)).toBe(true);
|
||||
expect(JSON.stringify(pendingPayload.cardsV2)).not.toContain("\\ud83d");
|
||||
});
|
||||
|
||||
it("preserves a complete astral character when it fits before the truncation suffix", async () => {
|
||||
const view = createPendingView();
|
||||
view.commandText = `${"a".repeat(1795)}😀${"b".repeat(100)}`;
|
||||
|
||||
const { pendingPayload } = await preparePendingDelivery(view);
|
||||
const commandText = getTextParagraphText(pendingPayload, "Command");
|
||||
|
||||
expect(commandText).toBe(`${"a".repeat(1795)}😀...`);
|
||||
expect(commandText.length).toBe(1800);
|
||||
expect(isUtf16WellFormed(commandText)).toBe(true);
|
||||
});
|
||||
|
||||
it("sends pending cards and updates the delivered message without buttons", async () => {
|
||||
sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-1" });
|
||||
updateGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-1" });
|
||||
|
||||
const view = createPendingView();
|
||||
const pendingPayload = await googleChatApprovalNativeRuntime.presentation.buildPendingPayload({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
context: { account },
|
||||
request: {
|
||||
id: "approval-1",
|
||||
request: { command: "echo hi" },
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: view.expiresAtMs,
|
||||
},
|
||||
approvalKind: "exec",
|
||||
nowMs: Date.now(),
|
||||
view,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(pendingPayload)).toContain("cardsV2");
|
||||
expect(JSON.stringify(pendingPayload.cardsV2)).toContain(
|
||||
"https://chat-app.example.test/googlechat",
|
||||
);
|
||||
expect(JSON.stringify(pendingPayload.cardsV2)).not.toContain("/approve approval-1 allow-once");
|
||||
|
||||
const prepared = await googleChatApprovalNativeRuntime.transport.prepareTarget({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
context: { account },
|
||||
plannedTarget: {
|
||||
surface: "origin",
|
||||
target: { to: "spaces/AAA", threadId: "threads/T1" },
|
||||
reason: "preferred",
|
||||
},
|
||||
request: {
|
||||
id: "approval-1",
|
||||
request: { command: "echo hi" },
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: view.expiresAtMs,
|
||||
},
|
||||
approvalKind: "exec",
|
||||
view,
|
||||
pendingPayload,
|
||||
});
|
||||
if (!prepared) {
|
||||
throw new Error("Expected prepared target");
|
||||
}
|
||||
const entry = await googleChatApprovalNativeRuntime.transport.deliverPending({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
context: { account },
|
||||
plannedTarget: {
|
||||
surface: "origin",
|
||||
target: { to: "spaces/AAA", threadId: "threads/T1" },
|
||||
reason: "preferred",
|
||||
},
|
||||
preparedTarget: prepared.target,
|
||||
request: {
|
||||
id: "approval-1",
|
||||
request: { command: "echo hi" },
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: view.expiresAtMs,
|
||||
},
|
||||
approvalKind: "exec",
|
||||
view,
|
||||
pendingPayload,
|
||||
});
|
||||
|
||||
expect(sendGoogleChatMessage).toHaveBeenCalledWith({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
cardsV2: expect.any(Array),
|
||||
thread: "threads/T1",
|
||||
});
|
||||
expect(sendGoogleChatMessage.mock.calls[0]?.[0]).not.toHaveProperty("text");
|
||||
expect(entry).toEqual({
|
||||
accountId: "default",
|
||||
spaceName: "spaces/AAA",
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
threadName: "threads/T1",
|
||||
actionTokens: expect.any(Array),
|
||||
});
|
||||
expect(
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupText(
|
||||
"Please reply with:\n/approve approval-1 allow-once",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const resolvedView: ResolvedApprovalView = {
|
||||
...view,
|
||||
phase: "resolved",
|
||||
decision: "allow-once",
|
||||
resolvedBy: "users/123",
|
||||
};
|
||||
const final = await googleChatApprovalNativeRuntime.presentation.buildResolvedResult({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
context: { account },
|
||||
request: {
|
||||
id: "approval-1",
|
||||
request: { command: "echo hi" },
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: view.expiresAtMs,
|
||||
},
|
||||
resolved: {
|
||||
id: "approval-1",
|
||||
decision: "allow-once",
|
||||
resolvedBy: "users/123",
|
||||
ts: Date.now(),
|
||||
},
|
||||
view: resolvedView,
|
||||
entry,
|
||||
});
|
||||
expect(final.kind).toBe("update");
|
||||
if (final.kind !== "update" || !entry) {
|
||||
throw new Error("Expected update result and entry");
|
||||
}
|
||||
await googleChatApprovalNativeRuntime.transport.updateEntry?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
context: { account },
|
||||
entry,
|
||||
payload: final.payload,
|
||||
phase: "resolved",
|
||||
});
|
||||
|
||||
expect(updateGoogleChatMessage).toHaveBeenCalledWith({
|
||||
account,
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
cardsV2: expect.any(Array),
|
||||
});
|
||||
expect(updateGoogleChatMessage.mock.calls[0]?.[0]).not.toHaveProperty("text");
|
||||
expect(JSON.stringify(final.payload)).not.toContain("buttonList");
|
||||
});
|
||||
|
||||
it("suppresses manual approval follow-ups while the native card send is in flight", async () => {
|
||||
const deferred = createDeferred<{ messageName: string }>();
|
||||
sendGoogleChatMessage.mockReturnValue(deferred.promise);
|
||||
const { pendingPayload, plannedTarget, prepared, request, view } =
|
||||
await preparePendingDelivery();
|
||||
|
||||
const deliveryPromise = googleChatApprovalNativeRuntime.transport.deliverPending({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
context: { account },
|
||||
plannedTarget,
|
||||
preparedTarget: prepared.target,
|
||||
request,
|
||||
approvalKind: "exec",
|
||||
view,
|
||||
pendingPayload,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(sendGoogleChatMessage).toHaveBeenCalled());
|
||||
expect(
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupText(
|
||||
"Please reply with:\n`/approve approval-1 allow-once`",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
deferred.resolve({ messageName: "spaces/AAA/messages/msg-1" });
|
||||
await expect(deliveryPromise).resolves.toEqual(
|
||||
expect.objectContaining({ messageName: "spaces/AAA/messages/msg-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores manual approval follow-ups when the native card send fails", async () => {
|
||||
sendGoogleChatMessage.mockRejectedValue(new Error("send failed"));
|
||||
const { pendingPayload, plannedTarget, prepared, request, view } =
|
||||
await preparePendingDelivery();
|
||||
|
||||
await expect(
|
||||
googleChatApprovalNativeRuntime.transport.deliverPending({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
context: { account },
|
||||
plannedTarget,
|
||||
preparedTarget: prepared.target,
|
||||
request,
|
||||
approvalKind: "exec",
|
||||
view,
|
||||
pendingPayload,
|
||||
}),
|
||||
).rejects.toThrow("send failed");
|
||||
expect(
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupText(
|
||||
"Please reply with:\n`/approve approval-1 allow-once`",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the named Chat action when app-url add-on principal binding is absent", async () => {
|
||||
const view = createPendingView();
|
||||
const pendingPayload = await googleChatApprovalNativeRuntime.presentation.buildPendingPayload({
|
||||
cfg: {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
client_email: "bot@example.com",
|
||||
private_key: "test-key",
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
audienceType: "app-url",
|
||||
audience: "https://chat-app.example.test/googlechat",
|
||||
dm: { allowFrom: ["users/123"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
context: {
|
||||
account: {
|
||||
...account,
|
||||
config: {
|
||||
audienceType: "app-url",
|
||||
audience: "https://chat-app.example.test/googlechat",
|
||||
},
|
||||
},
|
||||
},
|
||||
request: {
|
||||
id: "approval-1",
|
||||
request: { command: "echo hi" },
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: view.expiresAtMs,
|
||||
},
|
||||
approvalKind: "exec",
|
||||
nowMs: Date.now(),
|
||||
view,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(pendingPayload.cardsV2)).toContain("openclaw.approval");
|
||||
expect(JSON.stringify(pendingPayload.cardsV2)).not.toContain(
|
||||
"https://chat-app.example.test/googlechat",
|
||||
);
|
||||
});
|
||||
});
|
||||
414
extensions/googlechat/src/approval-handler.runtime.ts
Normal file
414
extensions/googlechat/src/approval-handler.runtime.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import type {
|
||||
ChannelApprovalCapabilityHandlerContext,
|
||||
ExpiredApprovalView,
|
||||
PendingApprovalView,
|
||||
ResolvedApprovalView,
|
||||
} from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
|
||||
import type { ExecApprovalDecision } from "openclaw/plugin-sdk/approval-runtime";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { resolveGoogleChatAccount, type ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js";
|
||||
import {
|
||||
buildGoogleChatApprovalActionParameters,
|
||||
createGoogleChatApprovalToken,
|
||||
GOOGLECHAT_APPROVAL_ACTION,
|
||||
registerGoogleChatApprovalCardBinding,
|
||||
registerGoogleChatManualApprovalFollowupSuppression,
|
||||
unregisterGoogleChatManualApprovalFollowupSuppression,
|
||||
unregisterGoogleChatApprovalCardBindings,
|
||||
} from "./approval-card-actions.js";
|
||||
import {
|
||||
isGoogleChatNativeApprovalClientEnabled,
|
||||
shouldHandleGoogleChatNativeApprovalRequest,
|
||||
} from "./approval-native.js";
|
||||
import { resolveGoogleChatOutboundSpace } from "./targets.js";
|
||||
import type { GoogleChatCardV2 } from "./types.js";
|
||||
|
||||
const log = createSubsystemLogger("googlechat/approvals");
|
||||
const GOOGLECHAT_APPROVAL_CARD_ID = "openclaw-approval";
|
||||
const MAX_TEXT_PARAGRAPH_CHARS = 1800;
|
||||
|
||||
type GoogleChatApprovalHandlerContext = {
|
||||
account?: ResolvedGoogleChatAccount;
|
||||
};
|
||||
|
||||
type GoogleChatApprovalActionToken = {
|
||||
token: string;
|
||||
decision: ExecApprovalDecision;
|
||||
};
|
||||
|
||||
type GoogleChatPendingDelivery = {
|
||||
approvalId: string;
|
||||
approvalKind: "exec" | "plugin";
|
||||
expiresAtMs: number;
|
||||
cardsV2: GoogleChatCardV2[];
|
||||
actionTokens: GoogleChatApprovalActionToken[];
|
||||
allowedDecisions: readonly ExecApprovalDecision[];
|
||||
};
|
||||
|
||||
type PreparedGoogleChatTarget = {
|
||||
to: string;
|
||||
threadName?: string;
|
||||
};
|
||||
|
||||
type GoogleChatPendingEntry = {
|
||||
accountId: string;
|
||||
spaceName: string;
|
||||
messageName: string;
|
||||
threadName?: string;
|
||||
actionTokens: GoogleChatApprovalActionToken[];
|
||||
};
|
||||
|
||||
type GoogleChatFinalDelivery = {
|
||||
cardsV2: GoogleChatCardV2[];
|
||||
};
|
||||
|
||||
function resolveHandlerAccount(
|
||||
params: ChannelApprovalCapabilityHandlerContext,
|
||||
): ResolvedGoogleChatAccount | null {
|
||||
const context = params.context as GoogleChatApprovalHandlerContext | undefined;
|
||||
const account =
|
||||
context?.account ??
|
||||
resolveGoogleChatAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (!account.enabled || account.credentialSource === "none") {
|
||||
return null;
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
function escapeGoogleChatText(text: string): string {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function truncateText(text: string, maxChars = MAX_TEXT_PARAGRAPH_CHARS): string {
|
||||
return text.length <= maxChars ? text : `${truncateUtf16Safe(text, maxChars - 3)}...`;
|
||||
}
|
||||
|
||||
function buildMetadataText(metadata: readonly { label: string; value: string }[]): string {
|
||||
return metadata
|
||||
.map(
|
||||
(item) => `<b>${escapeGoogleChatText(item.label)}:</b> ${escapeGoogleChatText(item.value)}`,
|
||||
)
|
||||
.join("<br>");
|
||||
}
|
||||
|
||||
function formatDecision(decision: ExecApprovalDecision): string {
|
||||
return decision === "allow-once"
|
||||
? "Allowed once"
|
||||
: decision === "allow-always"
|
||||
? "Allowed always"
|
||||
: "Denied";
|
||||
}
|
||||
|
||||
function buildMainTextWidget(text: string) {
|
||||
return {
|
||||
textParagraph: {
|
||||
text: escapeGoogleChatText(truncateText(text)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildHtmlTextWidget(text: string) {
|
||||
return {
|
||||
textParagraph: {
|
||||
text: truncateText(text),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildExecPendingSections(view: PendingApprovalView) {
|
||||
if (view.approvalKind !== "exec") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
header: "Command",
|
||||
widgets: [buildMainTextWidget(view.commandText)],
|
||||
},
|
||||
...(view.commandPreview && view.commandPreview !== view.commandText
|
||||
? [
|
||||
{
|
||||
header: "Preview",
|
||||
widgets: [buildMainTextWidget(view.commandPreview)],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
function buildPluginPendingSections(view: PendingApprovalView) {
|
||||
if (view.approvalKind !== "plugin") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
header: "Request",
|
||||
widgets: [
|
||||
buildHtmlTextWidget(
|
||||
`<b>${escapeGoogleChatText(view.title)}</b>${
|
||||
view.description ? `<br>${escapeGoogleChatText(view.description)}` : ""
|
||||
}`,
|
||||
),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildMetadataSection(
|
||||
view: PendingApprovalView | ResolvedApprovalView | ExpiredApprovalView,
|
||||
) {
|
||||
const metadata = [{ label: "Approval ID", value: view.approvalId }, ...view.metadata];
|
||||
return metadata.length > 0
|
||||
? [
|
||||
{
|
||||
header: "Details",
|
||||
widgets: [buildHtmlTextWidget(buildMetadataText(metadata))],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
function buildActionSection(params: { actionFunction: string; view: PendingApprovalView }): {
|
||||
section: NonNullable<GoogleChatCardV2["card"]["sections"]>[number];
|
||||
actionTokens: GoogleChatApprovalActionToken[];
|
||||
} {
|
||||
const { actionFunction, view } = params;
|
||||
const actionTokens = view.actions.map((action) => ({
|
||||
token: createGoogleChatApprovalToken(),
|
||||
decision: action.decision,
|
||||
}));
|
||||
return {
|
||||
actionTokens,
|
||||
section: {
|
||||
widgets: [
|
||||
{
|
||||
buttonList: {
|
||||
buttons: view.actions.map((action, index) => {
|
||||
const actionToken = actionTokens[index];
|
||||
if (!actionToken) {
|
||||
throw new Error("Google Chat approval action token missing.");
|
||||
}
|
||||
return {
|
||||
text: action.label,
|
||||
onClick: {
|
||||
action: {
|
||||
function: actionFunction,
|
||||
parameters: buildGoogleChatApprovalActionParameters(actionToken.token),
|
||||
loadIndicator: "SPINNER" as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildPendingPayload(params: {
|
||||
actionFunction: string;
|
||||
nowMs: number;
|
||||
view: PendingApprovalView;
|
||||
}): GoogleChatPendingDelivery {
|
||||
const { actionFunction, nowMs, view } = params;
|
||||
const { section: actionSection, actionTokens } = buildActionSection({ actionFunction, view });
|
||||
const title =
|
||||
view.approvalKind === "plugin" ? "Plugin Approval Required" : "Exec Approval Required";
|
||||
const subtitle = `Expires in ${Math.max(0, Math.ceil((view.expiresAtMs - nowMs) / 1000))}s`;
|
||||
const card: GoogleChatCardV2 = {
|
||||
cardId: GOOGLECHAT_APPROVAL_CARD_ID,
|
||||
card: {
|
||||
header: { title, subtitle },
|
||||
sections: [
|
||||
...buildExecPendingSections(view),
|
||||
...buildPluginPendingSections(view),
|
||||
...buildMetadataSection(view),
|
||||
actionSection,
|
||||
],
|
||||
},
|
||||
};
|
||||
return {
|
||||
approvalId: view.approvalId,
|
||||
approvalKind: view.approvalKind,
|
||||
expiresAtMs: view.expiresAtMs,
|
||||
cardsV2: [card],
|
||||
actionTokens,
|
||||
allowedDecisions: view.actions.map((action) => action.decision),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveApprovalActionFunction(params: ChannelApprovalCapabilityHandlerContext): string {
|
||||
const account = resolveHandlerAccount(params);
|
||||
const audience = normalizeOptionalString(account?.config.audience);
|
||||
const appPrincipal = normalizeOptionalString(account?.config.appPrincipal);
|
||||
return account?.config.audienceType === "app-url" && audience && appPrincipal
|
||||
? audience
|
||||
: GOOGLECHAT_APPROVAL_ACTION;
|
||||
}
|
||||
|
||||
function buildResolvedPayload(view: ResolvedApprovalView): GoogleChatFinalDelivery {
|
||||
const resolvedBy = normalizeOptionalString(view.resolvedBy);
|
||||
const card: GoogleChatCardV2 = {
|
||||
cardId: GOOGLECHAT_APPROVAL_CARD_ID,
|
||||
card: {
|
||||
header: {
|
||||
title: `${view.approvalKind === "plugin" ? "Plugin" : "Exec"} Approval: ${formatDecision(
|
||||
view.decision,
|
||||
)}`,
|
||||
subtitle: resolvedBy ? `Resolved by ${resolvedBy}` : "Resolved",
|
||||
},
|
||||
sections: buildMetadataSection(view),
|
||||
},
|
||||
};
|
||||
return {
|
||||
cardsV2: [card],
|
||||
};
|
||||
}
|
||||
|
||||
function buildExpiredPayload(view: ExpiredApprovalView): GoogleChatFinalDelivery {
|
||||
const card: GoogleChatCardV2 = {
|
||||
cardId: GOOGLECHAT_APPROVAL_CARD_ID,
|
||||
card: {
|
||||
header: {
|
||||
title: `${view.approvalKind === "plugin" ? "Plugin" : "Exec"} Approval Expired`,
|
||||
subtitle: "This approval request expired before it was resolved.",
|
||||
},
|
||||
sections: buildMetadataSection(view),
|
||||
},
|
||||
};
|
||||
return {
|
||||
cardsV2: [card],
|
||||
};
|
||||
}
|
||||
|
||||
export const googleChatApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
|
||||
GoogleChatPendingDelivery,
|
||||
PreparedGoogleChatTarget,
|
||||
GoogleChatPendingEntry,
|
||||
readonly string[],
|
||||
GoogleChatFinalDelivery
|
||||
>({
|
||||
eventKinds: ["exec", "plugin"],
|
||||
availability: {
|
||||
isConfigured: ({ cfg, accountId }) =>
|
||||
isGoogleChatNativeApprovalClientEnabled({ cfg, accountId }),
|
||||
shouldHandle: ({ cfg, accountId, request }) =>
|
||||
shouldHandleGoogleChatNativeApprovalRequest({ cfg, accountId, request }),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: ({ cfg, accountId, context, nowMs, view }) =>
|
||||
buildPendingPayload({
|
||||
actionFunction: resolveApprovalActionFunction({ cfg, accountId, context }),
|
||||
nowMs,
|
||||
view,
|
||||
}),
|
||||
buildResolvedResult: ({ view }) => ({ kind: "update", payload: buildResolvedPayload(view) }),
|
||||
buildExpiredResult: ({ view }) => ({ kind: "update", payload: buildExpiredPayload(view) }),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: ({ plannedTarget }) => ({
|
||||
dedupeKey: buildChannelApprovalNativeTargetKey(plannedTarget.target),
|
||||
target: {
|
||||
to: plannedTarget.target.to,
|
||||
threadName:
|
||||
plannedTarget.target.threadId != null ? String(plannedTarget.target.threadId) : undefined,
|
||||
},
|
||||
}),
|
||||
deliverPending: async ({ cfg, accountId, context, preparedTarget, pendingPayload }) => {
|
||||
const account = resolveHandlerAccount({ cfg, accountId, context });
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
const spaceName = await resolveGoogleChatOutboundSpace({
|
||||
account,
|
||||
target: preparedTarget.to,
|
||||
});
|
||||
// Native delivery can race the model's message tool follow-up; register before
|
||||
// the send awaits so the channel-local outbound filter can suppress duplicates.
|
||||
registerGoogleChatManualApprovalFollowupSuppression({
|
||||
approvalId: pendingPayload.approvalId,
|
||||
approvalKind: pendingPayload.approvalKind,
|
||||
allowedDecisions: pendingPayload.allowedDecisions,
|
||||
expiresAtMs: pendingPayload.expiresAtMs,
|
||||
});
|
||||
let sent: Awaited<ReturnType<typeof sendGoogleChatMessage>>;
|
||||
try {
|
||||
sent = await sendGoogleChatMessage({
|
||||
account,
|
||||
space: spaceName,
|
||||
cardsV2: pendingPayload.cardsV2,
|
||||
thread: preparedTarget.threadName,
|
||||
});
|
||||
} catch (error) {
|
||||
unregisterGoogleChatManualApprovalFollowupSuppression(pendingPayload.approvalId);
|
||||
throw error;
|
||||
}
|
||||
if (!sent?.messageName) {
|
||||
unregisterGoogleChatManualApprovalFollowupSuppression(pendingPayload.approvalId);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
spaceName,
|
||||
messageName: sent.messageName,
|
||||
...(preparedTarget.threadName ? { threadName: preparedTarget.threadName } : {}),
|
||||
actionTokens: pendingPayload.actionTokens,
|
||||
};
|
||||
},
|
||||
updateEntry: async ({ cfg, accountId, context, entry, payload }) => {
|
||||
const account = resolveHandlerAccount({ cfg, accountId, context });
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
await updateGoogleChatMessage({
|
||||
account,
|
||||
messageName: entry.messageName,
|
||||
cardsV2: payload.cardsV2,
|
||||
});
|
||||
},
|
||||
},
|
||||
interactions: {
|
||||
bindPending: ({ entry, request, approvalKind, view, pendingPayload }) => {
|
||||
const tokens: string[] = [];
|
||||
for (const actionToken of entry.actionTokens) {
|
||||
const ok = registerGoogleChatApprovalCardBinding({
|
||||
token: actionToken.token,
|
||||
accountId: entry.accountId,
|
||||
approvalId: request.id,
|
||||
approvalKind,
|
||||
decision: actionToken.decision,
|
||||
allowedDecisions: pendingPayload.allowedDecisions,
|
||||
spaceName: entry.spaceName,
|
||||
messageName: entry.messageName,
|
||||
threadName: entry.threadName ?? null,
|
||||
expiresAtMs: view.expiresAtMs,
|
||||
});
|
||||
if (ok) {
|
||||
tokens.push(actionToken.token);
|
||||
}
|
||||
}
|
||||
return tokens.length > 0 ? tokens : null;
|
||||
},
|
||||
unbindPending: ({ binding }) => {
|
||||
unregisterGoogleChatApprovalCardBindings(binding);
|
||||
},
|
||||
cancelDelivered: ({ entry }) => {
|
||||
unregisterGoogleChatApprovalCardBindings(
|
||||
entry.actionTokens.map((actionToken) => actionToken.token),
|
||||
);
|
||||
},
|
||||
},
|
||||
observe: {
|
||||
onDeliveryError: ({ error, request }) => {
|
||||
log.error(`googlechat approvals: failed to send request ${request.id}: ${String(error)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
399
extensions/googlechat/src/approval-native.test.ts
Normal file
399
extensions/googlechat/src/approval-native.test.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import type { ChannelOutboundPayloadHint } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
googleChatApprovalCapability,
|
||||
shouldHandleGoogleChatNativeApprovalRequest,
|
||||
shouldSuppressLocalGoogleChatExecApprovalPrompt,
|
||||
} from "./approval-native.js";
|
||||
|
||||
const GOOGLE_CHAT_APPROVAL_ACCOUNT = {
|
||||
serviceAccount: {
|
||||
type: "service_account" as const,
|
||||
client_email: "bot@example.com",
|
||||
private_key: "test-key",
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
audienceType: "app-url" as const,
|
||||
audience: "https://chat-app.example.test/googlechat",
|
||||
appPrincipal: "123456789012345678901",
|
||||
dm: { allowFrom: ["users/123"] },
|
||||
};
|
||||
|
||||
const execApprovalPayload: ReplyPayload = {
|
||||
text: "I need approval to run this command.",
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "12345678-1234-1234-1234-123456789012",
|
||||
approvalSlug: "12345678",
|
||||
approvalKind: "exec",
|
||||
agentId: "dev",
|
||||
sessionKey: "agent:dev:main",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const activeExecApprovalHint: ChannelOutboundPayloadHint = {
|
||||
kind: "approval-pending",
|
||||
approvalKind: "exec",
|
||||
nativeRouteActive: true,
|
||||
};
|
||||
|
||||
describe("googleChatApprovalCapability", () => {
|
||||
it("declares native exec and plugin approval runtime support", async () => {
|
||||
const runtime = googleChatApprovalCapability.nativeRuntime;
|
||||
expect(runtime?.eventKinds).toEqual(["exec", "plugin"]);
|
||||
expect(
|
||||
runtime?.availability.isConfigured({
|
||||
cfg: {
|
||||
approvals: { exec: { enabled: true } },
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
client_email: "bot@example.com",
|
||||
private_key: "test-key",
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
audienceType: "app-url",
|
||||
audience: "https://chat-app.example.test/googlechat",
|
||||
appPrincipal: "123456789012345678901",
|
||||
dm: { allowFrom: ["users/123"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not enable native cards when webhook callback audience auth is incomplete", async () => {
|
||||
const runtime = googleChatApprovalCapability.nativeRuntime;
|
||||
expect(
|
||||
runtime?.availability.isConfigured({
|
||||
cfg: {
|
||||
approvals: { exec: { enabled: true } },
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
client_email: "bot@example.com",
|
||||
private_key: "test-key",
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
dm: { allowFrom: ["users/123"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
runtime?.availability.isConfigured({
|
||||
cfg: {
|
||||
approvals: { exec: { enabled: true } },
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
client_email: "bot@example.com",
|
||||
private_key: "test-key",
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
audienceType: "project-number",
|
||||
dm: { allowFrom: ["users/123"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a top-level approval forwarding route before enabling native cards", async () => {
|
||||
const runtime = googleChatApprovalCapability.nativeRuntime;
|
||||
const googlechat = {
|
||||
serviceAccount: {
|
||||
type: "service_account" as const,
|
||||
client_email: "bot@example.com",
|
||||
private_key: "test-key",
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
audienceType: "app-url" as const,
|
||||
audience: "https://chat-app.example.test/googlechat",
|
||||
dm: { allowFrom: ["users/123"] },
|
||||
};
|
||||
|
||||
expect(
|
||||
runtime?.availability.isConfigured({
|
||||
cfg: { channels: { googlechat } },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
runtime?.availability.isConfigured({
|
||||
cfg: {
|
||||
approvals: { exec: { enabled: false } },
|
||||
channels: { googlechat },
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
runtime?.availability.isConfigured({
|
||||
cfg: {
|
||||
approvals: { exec: { enabled: true, mode: "targets" } },
|
||||
channels: { googlechat },
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
runtime?.availability.isConfigured({
|
||||
cfg: {
|
||||
approvals: { plugin: { enabled: true } },
|
||||
channels: { googlechat },
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("enables native cards for supported webhook audience modes", async () => {
|
||||
const runtime = googleChatApprovalCapability.nativeRuntime;
|
||||
expect(
|
||||
runtime?.availability.isConfigured({
|
||||
cfg: {
|
||||
approvals: { exec: { enabled: true } },
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
client_email: "bot@example.com",
|
||||
private_key: "test-key",
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
audienceType: "app-url",
|
||||
audience: "https://chat-app.example.test/googlechat",
|
||||
dm: { allowFrom: ["users/123"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
runtime?.availability.isConfigured({
|
||||
cfg: {
|
||||
approvals: { exec: { enabled: true } },
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
client_email: "bot@example.com",
|
||||
private_key: "test-key",
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
audienceType: "project-number",
|
||||
audience: "1234567890",
|
||||
dm: { allowFrom: ["users/123"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves Google Chat approval actor authorization", () => {
|
||||
expect(
|
||||
googleChatApprovalCapability.authorizeActorAction?.({
|
||||
cfg: { channels: { googlechat: { dm: { allowFrom: ["users/123"] } } } },
|
||||
senderId: "users/123",
|
||||
action: "approve",
|
||||
approvalKind: "plugin",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
|
||||
expect(
|
||||
googleChatApprovalCapability.authorizeActorAction?.({
|
||||
cfg: { channels: { googlechat: { dm: { allowFrom: ["users/123"] } } } },
|
||||
senderId: "users/999",
|
||||
action: "approve",
|
||||
approvalKind: "plugin",
|
||||
}),
|
||||
).toEqual({
|
||||
authorized: false,
|
||||
reason: "❌ You are not authorized to approve plugin requests on Google Chat.",
|
||||
});
|
||||
});
|
||||
|
||||
it("only handles approvals for the originating Google Chat account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
approvals: { exec: { enabled: true } },
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
alpha: {
|
||||
enabled: true,
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
client_email: "alpha@example.com",
|
||||
private_key: "test-key",
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
audienceType: "app-url",
|
||||
audience: "https://alpha.example.com/googlechat",
|
||||
appPrincipal: "123456789012345678901",
|
||||
dm: { allowFrom: ["users/123"] },
|
||||
},
|
||||
beta: {
|
||||
enabled: true,
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
client_email: "beta@example.com",
|
||||
private_key: "test-key",
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
audienceType: "app-url",
|
||||
audience: "https://beta.example.com/googlechat",
|
||||
appPrincipal: "987654321098765432109",
|
||||
dm: { allowFrom: ["users/456"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const request = {
|
||||
id: "approval-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "googlechat",
|
||||
turnSourceAccountId: "alpha",
|
||||
turnSourceTo: "spaces/AAA",
|
||||
},
|
||||
} as never;
|
||||
|
||||
expect(
|
||||
shouldHandleGoogleChatNativeApprovalRequest({
|
||||
cfg,
|
||||
accountId: "alpha",
|
||||
request,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldHandleGoogleChatNativeApprovalRequest({
|
||||
cfg,
|
||||
accountId: "beta",
|
||||
request,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not handle exec approvals when only plugin approval forwarding is enabled", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
approvals: { plugin: { enabled: true } },
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
client_email: "bot@example.com",
|
||||
private_key: "test-key",
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
audienceType: "app-url",
|
||||
audience: "https://chat-app.example.test/googlechat",
|
||||
appPrincipal: "123456789012345678901",
|
||||
dm: { allowFrom: ["users/123"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
const request = {
|
||||
id: "approval-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "googlechat",
|
||||
turnSourceTo: "spaces/AAA",
|
||||
},
|
||||
} as never;
|
||||
|
||||
expect(
|
||||
shouldHandleGoogleChatNativeApprovalRequest({
|
||||
cfg,
|
||||
request,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses the local exec prompt when a Google Chat native route is active", () => {
|
||||
expect(
|
||||
shouldSuppressLocalGoogleChatExecApprovalPrompt({
|
||||
cfg: {
|
||||
approvals: { exec: { enabled: true } },
|
||||
channels: { googlechat: GOOGLE_CHAT_APPROVAL_ACCOUNT },
|
||||
},
|
||||
payload: execApprovalPayload,
|
||||
hint: activeExecApprovalHint,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the local exec prompt when native Google Chat delivery cannot own it", () => {
|
||||
expect(
|
||||
shouldSuppressLocalGoogleChatExecApprovalPrompt({
|
||||
cfg: {
|
||||
approvals: { exec: { enabled: true } },
|
||||
channels: { googlechat: GOOGLE_CHAT_APPROVAL_ACCOUNT },
|
||||
},
|
||||
payload: execApprovalPayload,
|
||||
hint: {
|
||||
kind: "approval-pending",
|
||||
approvalKind: "exec",
|
||||
nativeRouteActive: false,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldSuppressLocalGoogleChatExecApprovalPrompt({
|
||||
cfg: {
|
||||
approvals: { exec: { enabled: false } },
|
||||
channels: { googlechat: GOOGLE_CHAT_APPROVAL_ACCOUNT },
|
||||
},
|
||||
payload: execApprovalPayload,
|
||||
hint: activeExecApprovalHint,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldSuppressLocalGoogleChatExecApprovalPrompt({
|
||||
cfg: {
|
||||
approvals: { exec: { enabled: true } },
|
||||
channels: {
|
||||
googlechat: {
|
||||
...GOOGLE_CHAT_APPROVAL_ACCOUNT,
|
||||
audience: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
payload: execApprovalPayload,
|
||||
hint: activeExecApprovalHint,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldSuppressLocalGoogleChatExecApprovalPrompt({
|
||||
cfg: {
|
||||
approvals: { exec: { enabled: true } },
|
||||
channels: { googlechat: GOOGLE_CHAT_APPROVAL_ACCOUNT },
|
||||
},
|
||||
payload: {
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "12345678-1234-1234-1234-123456789012",
|
||||
approvalSlug: "12345678",
|
||||
approvalKind: "plugin",
|
||||
},
|
||||
},
|
||||
},
|
||||
hint: {
|
||||
kind: "approval-pending",
|
||||
approvalKind: "plugin",
|
||||
nativeRouteActive: true,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
239
extensions/googlechat/src/approval-native.ts
Normal file
239
extensions/googlechat/src/approval-native.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { createApproverRestrictedNativeApprovalCapability } from "openclaw/plugin-sdk/approval-delivery-runtime";
|
||||
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
|
||||
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import {
|
||||
createChannelApproverDmTargetResolver,
|
||||
createChannelNativeOriginTargetResolver,
|
||||
createNativeApprovalChannelRouteGates,
|
||||
shouldSuppressLocalNativeExecApprovalPrompt,
|
||||
} from "openclaw/plugin-sdk/approval-native-runtime";
|
||||
import type {
|
||||
ExecApprovalRequest,
|
||||
PluginApprovalRequest,
|
||||
} from "openclaw/plugin-sdk/approval-runtime";
|
||||
import type {
|
||||
ChannelApprovalCapability,
|
||||
ChannelOutboundPayloadHint,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
listGoogleChatAccountIds,
|
||||
resolveDefaultGoogleChatAccountId,
|
||||
resolveGoogleChatAccount,
|
||||
} from "./accounts.js";
|
||||
import {
|
||||
getGoogleChatApprovalApprovers,
|
||||
googleChatApprovalAuth,
|
||||
normalizeGoogleChatApproverId,
|
||||
} from "./approval-auth.js";
|
||||
import { isGoogleChatSpaceTarget, normalizeGoogleChatTarget } from "./targets.js";
|
||||
|
||||
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
|
||||
type GoogleChatApprovalTarget = {
|
||||
to: string;
|
||||
accountId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
};
|
||||
type ChannelApprovalForwardTarget = Parameters<
|
||||
NonNullable<
|
||||
NonNullable<ChannelApprovalCapability["delivery"]>["shouldSuppressForwardingFallback"]
|
||||
>
|
||||
>[0]["target"];
|
||||
|
||||
const DEFAULT_APPROVAL_FORWARDING_MODE = "session";
|
||||
|
||||
function isGoogleChatAccountConfigured(params: {
|
||||
cfg: Parameters<typeof resolveGoogleChatAccount>[0]["cfg"];
|
||||
accountId?: string | null;
|
||||
}): boolean {
|
||||
const account = resolveGoogleChatAccount(params);
|
||||
return account.enabled && account.credentialSource !== "none";
|
||||
}
|
||||
|
||||
function hasGoogleChatWebhookApprovalAuthConfig(params: {
|
||||
cfg: Parameters<typeof resolveGoogleChatAccount>[0]["cfg"];
|
||||
accountId?: string | null;
|
||||
}): boolean {
|
||||
const account = resolveGoogleChatAccount(params).config;
|
||||
const audience = normalizeOptionalString(account.audience);
|
||||
if (!audience) {
|
||||
return false;
|
||||
}
|
||||
if (account.audienceType === "project-number") {
|
||||
return true;
|
||||
}
|
||||
return account.audienceType === "app-url";
|
||||
}
|
||||
|
||||
function isGoogleChatApprovalTransportEnabled(params: {
|
||||
cfg: Parameters<typeof resolveGoogleChatAccount>[0]["cfg"];
|
||||
accountId?: string | null;
|
||||
}): boolean {
|
||||
return isGoogleChatAccountConfigured(params) && hasGoogleChatWebhookApprovalAuthConfig(params);
|
||||
}
|
||||
|
||||
function normalizeGoogleChatForwardTarget(
|
||||
target: Pick<ChannelApprovalForwardTarget, "channel" | "to" | "accountId" | "threadId">,
|
||||
): GoogleChatApprovalTarget | null {
|
||||
if (normalizeLowercaseStringOrEmpty(target.channel) !== "googlechat") {
|
||||
return null;
|
||||
}
|
||||
const to = normalizeGoogleChatTarget(target.to);
|
||||
return to
|
||||
? {
|
||||
to,
|
||||
accountId: normalizeOptionalString(target.accountId),
|
||||
threadId: target.threadId ?? null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveTurnSourceGoogleChatOriginTarget(
|
||||
request: ApprovalRequest,
|
||||
): GoogleChatApprovalTarget | null {
|
||||
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
|
||||
if (turnSourceChannel !== "googlechat") {
|
||||
return null;
|
||||
}
|
||||
const target = normalizeGoogleChatTarget(request.request.turnSourceTo ?? "");
|
||||
if (!target || !isGoogleChatSpaceTarget(target)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: target,
|
||||
accountId: normalizeOptionalString(request.request.turnSourceAccountId),
|
||||
threadId: request.request.turnSourceThreadId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const googleChatApprovalRouteGates = createNativeApprovalChannelRouteGates({
|
||||
channel: "googlechat",
|
||||
defaultForwardingMode: DEFAULT_APPROVAL_FORWARDING_MODE,
|
||||
isTransportEnabled: isGoogleChatApprovalTransportEnabled,
|
||||
listAccountIds: listGoogleChatAccountIds,
|
||||
resolveDefaultAccountId: resolveDefaultGoogleChatAccountId,
|
||||
normalizeForwardTarget: normalizeGoogleChatForwardTarget,
|
||||
resolveTurnSourceTarget: resolveTurnSourceGoogleChatOriginTarget,
|
||||
});
|
||||
|
||||
export function isGoogleChatNativeApprovalClientEnabled(params: {
|
||||
cfg: Parameters<typeof resolveGoogleChatAccount>[0]["cfg"];
|
||||
accountId?: string | null;
|
||||
}): boolean {
|
||||
return (
|
||||
googleChatApprovalRouteGates.canAnyApprovalPotentiallyRouteToChannel({
|
||||
...params,
|
||||
nativeSessionOnly: true,
|
||||
}) && getGoogleChatApprovalApprovers(params).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSessionGoogleChatOriginTarget(sessionTarget: {
|
||||
to: string;
|
||||
threadId?: string | number | null;
|
||||
}): GoogleChatApprovalTarget | null {
|
||||
const target = normalizeGoogleChatTarget(sessionTarget.to);
|
||||
return target && isGoogleChatSpaceTarget(target)
|
||||
? { to: target, threadId: sessionTarget.threadId ?? null }
|
||||
: null;
|
||||
}
|
||||
|
||||
export function shouldHandleGoogleChatNativeApprovalRequest(params: {
|
||||
cfg: Parameters<typeof resolveGoogleChatAccount>[0]["cfg"];
|
||||
accountId?: string | null;
|
||||
request: ApprovalRequest;
|
||||
}): boolean {
|
||||
return (
|
||||
googleChatApprovalRouteGates.shouldHandleApprovalRequest(params) &&
|
||||
getGoogleChatApprovalApprovers(params).length > 0 &&
|
||||
Boolean(resolveTurnSourceGoogleChatOriginTarget(params.request))
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldSuppressLocalGoogleChatExecApprovalPrompt(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
payload: ReplyPayload;
|
||||
hint?: ChannelOutboundPayloadHint;
|
||||
}): boolean {
|
||||
return shouldSuppressLocalNativeExecApprovalPrompt({
|
||||
...params,
|
||||
isNativeDeliveryEnabled: isGoogleChatNativeApprovalClientEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
const resolveGoogleChatOriginTarget = createChannelNativeOriginTargetResolver({
|
||||
channel: "googlechat",
|
||||
shouldHandleRequest: shouldHandleGoogleChatNativeApprovalRequest,
|
||||
resolveTurnSourceTarget: resolveTurnSourceGoogleChatOriginTarget,
|
||||
resolveSessionTarget: resolveSessionGoogleChatOriginTarget,
|
||||
});
|
||||
|
||||
const resolveGoogleChatApproverDmTargets = createChannelApproverDmTargetResolver({
|
||||
shouldHandleRequest: shouldHandleGoogleChatNativeApprovalRequest,
|
||||
resolveApprovers: getGoogleChatApprovalApprovers,
|
||||
mapApprover: (approver, params) => {
|
||||
const to = normalizeGoogleChatApproverId(approver);
|
||||
return to
|
||||
? {
|
||||
to,
|
||||
accountId: normalizeOptionalString(params.accountId),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
});
|
||||
|
||||
export const googleChatApprovalCapability: ChannelApprovalCapability =
|
||||
createApproverRestrictedNativeApprovalCapability({
|
||||
channel: "googlechat",
|
||||
channelLabel: "Google Chat",
|
||||
describeExecApprovalSetup: ({ accountId }) => {
|
||||
const prefix =
|
||||
accountId && accountId !== "default"
|
||||
? `channels.googlechat.accounts.${accountId}`
|
||||
: "channels.googlechat";
|
||||
return `Approve it from the Web UI or terminal UI for now. Google Chat supports native approvals for this account when the webhook and service account are configured. Configure \`${prefix}.dm.allowFrom\` or \`${prefix}.defaultTo\` with numeric \`users/{id}\` approvers.`;
|
||||
},
|
||||
listAccountIds: listGoogleChatAccountIds,
|
||||
hasApprovers: ({ cfg, accountId }) =>
|
||||
getGoogleChatApprovalApprovers({ cfg, accountId }).length > 0,
|
||||
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
|
||||
googleChatApprovalAuth.authorizeActorAction?.({
|
||||
cfg,
|
||||
accountId,
|
||||
senderId,
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
})?.authorized ?? false,
|
||||
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) =>
|
||||
googleChatApprovalAuth.authorizeActorAction?.({
|
||||
cfg,
|
||||
accountId,
|
||||
senderId,
|
||||
action: "approve",
|
||||
approvalKind: "plugin",
|
||||
})?.authorized ?? false,
|
||||
isNativeDeliveryEnabled: isGoogleChatNativeApprovalClientEnabled,
|
||||
resolveNativeDeliveryMode: () => "channel",
|
||||
requireMatchingTurnSourceChannel: true,
|
||||
resolveSuppressionAccountId: ({ target, request }) =>
|
||||
normalizeOptionalString(target.accountId) ??
|
||||
normalizeOptionalString(request.request.turnSourceAccountId),
|
||||
resolveOriginTarget: resolveGoogleChatOriginTarget,
|
||||
resolveApproverDmTargets: resolveGoogleChatApproverDmTargets,
|
||||
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
|
||||
eventKinds: ["exec", "plugin"],
|
||||
isConfigured: ({ cfg, accountId }) =>
|
||||
isGoogleChatNativeApprovalClientEnabled({ cfg, accountId }),
|
||||
shouldHandle: ({ cfg, accountId, request }) =>
|
||||
shouldHandleGoogleChatNativeApprovalRequest({ cfg, accountId, request }),
|
||||
load: async () =>
|
||||
(await import("./approval-handler.runtime.js"))
|
||||
.googleChatApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter,
|
||||
}),
|
||||
});
|
||||
219
extensions/googlechat/src/auth.ts
Normal file
219
extensions/googlechat/src/auth.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
// Googlechat plugin module implements auth behavior.
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { fetchWithSsrFGuard } from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import {
|
||||
testing as googleAuthRuntimeTesting,
|
||||
getGoogleAuthTransport,
|
||||
loadGoogleAuthRuntime,
|
||||
resolveValidatedGoogleChatCredentials,
|
||||
} from "./google-auth.runtime.js";
|
||||
|
||||
const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
|
||||
const CHAT_ISSUER = "chat@system.gserviceaccount.com";
|
||||
// Google Workspace Add-ons use a different service account pattern
|
||||
const ADDON_ISSUER_PATTERN = /^service-\d+@gcp-sa-gsuiteaddons\.iam\.gserviceaccount\.com$/;
|
||||
const CHAT_CERTS_URL =
|
||||
"https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com";
|
||||
|
||||
async function readGoogleChatCertsResponse(response: Response): Promise<Record<string, string>> {
|
||||
return readProviderJsonResponse<Record<string, string>>(
|
||||
response,
|
||||
"Google Chat cert fetch failed",
|
||||
);
|
||||
}
|
||||
|
||||
// Size-capped to prevent unbounded growth in long-running deployments (#4948)
|
||||
const MAX_AUTH_CACHE_SIZE = 32;
|
||||
type GoogleAuthModule = typeof import("google-auth-library");
|
||||
type GoogleAuthRuntime = {
|
||||
GoogleAuth: GoogleAuthModule["GoogleAuth"];
|
||||
OAuth2Client: GoogleAuthModule["OAuth2Client"];
|
||||
};
|
||||
type GoogleAuthInstance = InstanceType<GoogleAuthRuntime["GoogleAuth"]>;
|
||||
type GoogleAuthOptions = ConstructorParameters<GoogleAuthRuntime["GoogleAuth"]>[0];
|
||||
type GoogleAuthTransport = NonNullable<GoogleAuthOptions>["clientOptions"] extends {
|
||||
transporter?: infer T;
|
||||
}
|
||||
? T
|
||||
: never;
|
||||
type OAuth2ClientInstance = InstanceType<GoogleAuthRuntime["OAuth2Client"]>;
|
||||
|
||||
const authCache = new Map<string, { key: string; auth: GoogleAuthInstance }>();
|
||||
|
||||
let cachedCerts: { fetchedAt: number; certs: Record<string, string> } | null = null;
|
||||
let verifyClientPromise: Promise<OAuth2ClientInstance> | null = null;
|
||||
|
||||
async function getVerifyClient(): Promise<OAuth2ClientInstance> {
|
||||
if (!verifyClientPromise) {
|
||||
verifyClientPromise = (async () => {
|
||||
try {
|
||||
const { OAuth2Client } = await loadGoogleAuthRuntime();
|
||||
// google-auth-library types its transporter through gaxios' CJS surface,
|
||||
// while the plugin imports the ESM entrypoint directly.
|
||||
const transporter = (await getGoogleAuthTransport()) as unknown as GoogleAuthTransport;
|
||||
return new OAuth2Client({ transporter });
|
||||
} catch (error) {
|
||||
verifyClientPromise = null;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return await verifyClientPromise;
|
||||
}
|
||||
|
||||
function buildAuthKey(account: ResolvedGoogleChatAccount): string {
|
||||
if (account.credentialsFile) {
|
||||
return `file:${account.credentialsFile}`;
|
||||
}
|
||||
if (account.credentials) {
|
||||
return `inline:${JSON.stringify(account.credentials)}`;
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
async function getAuthInstance(account: ResolvedGoogleChatAccount): Promise<GoogleAuthInstance> {
|
||||
const key = buildAuthKey(account);
|
||||
const cached = authCache.get(account.accountId);
|
||||
if (cached && cached.key === key) {
|
||||
return cached.auth;
|
||||
}
|
||||
const [{ GoogleAuth }, rawTransporter, credentials] = await Promise.all([
|
||||
loadGoogleAuthRuntime(),
|
||||
getGoogleAuthTransport(),
|
||||
resolveValidatedGoogleChatCredentials(account),
|
||||
]);
|
||||
const transporter = rawTransporter as unknown as GoogleAuthTransport;
|
||||
|
||||
const evictOldest = () => {
|
||||
if (authCache.size > MAX_AUTH_CACHE_SIZE) {
|
||||
const oldest = authCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
authCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const auth = new GoogleAuth({
|
||||
...(credentials ? { credentials } : {}),
|
||||
clientOptions: { transporter },
|
||||
scopes: [CHAT_SCOPE],
|
||||
});
|
||||
authCache.set(account.accountId, { key, auth });
|
||||
evictOldest();
|
||||
return auth;
|
||||
}
|
||||
|
||||
export async function getGoogleChatAccessToken(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
): Promise<string> {
|
||||
const auth = await getAuthInstance(account);
|
||||
const client = await auth.getClient();
|
||||
const access = await client.getAccessToken();
|
||||
const token = typeof access === "string" ? access : access?.token;
|
||||
if (!token) {
|
||||
throw new Error("Missing Google Chat access token");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function fetchChatCerts(): Promise<Record<string, string>> {
|
||||
const now = Date.now();
|
||||
if (cachedCerts && now - cachedCerts.fetchedAt < 10 * 60 * 1000) {
|
||||
return cachedCerts.certs;
|
||||
}
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: CHAT_CERTS_URL,
|
||||
auditContext: "googlechat.auth.certs",
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Chat certs (${response.status})`);
|
||||
}
|
||||
const certs = await readGoogleChatCertsResponse(response);
|
||||
cachedCerts = { fetchedAt: now, certs };
|
||||
return certs;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export type GoogleChatAudienceType = "app-url" | "project-number";
|
||||
|
||||
export async function verifyGoogleChatRequest(params: {
|
||||
bearer?: string | null;
|
||||
audienceType?: GoogleChatAudienceType | null;
|
||||
audience?: string | null;
|
||||
expectedAddOnPrincipal?: string | null;
|
||||
}): Promise<{ ok: boolean; reason?: string }> {
|
||||
const bearer = params.bearer?.trim();
|
||||
if (!bearer) {
|
||||
return { ok: false, reason: "missing token" };
|
||||
}
|
||||
const audience = params.audience?.trim();
|
||||
if (!audience) {
|
||||
return { ok: false, reason: "missing audience" };
|
||||
}
|
||||
const audienceType = params.audienceType ?? null;
|
||||
|
||||
if (audienceType === "app-url") {
|
||||
try {
|
||||
const verifyClient = await getVerifyClient();
|
||||
const ticket = await verifyClient.verifyIdToken({
|
||||
idToken: bearer,
|
||||
audience,
|
||||
});
|
||||
const payload = ticket.getPayload();
|
||||
const email = normalizeLowercaseStringOrEmpty(payload?.email ?? "");
|
||||
if (!payload?.email_verified) {
|
||||
return { ok: false, reason: "email not verified" };
|
||||
}
|
||||
if (email === CHAT_ISSUER) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (!ADDON_ISSUER_PATTERN.test(email)) {
|
||||
return { ok: false, reason: `invalid issuer: ${email}` };
|
||||
}
|
||||
const expectedAddOnPrincipal = normalizeLowercaseStringOrEmpty(
|
||||
params.expectedAddOnPrincipal ?? "",
|
||||
);
|
||||
if (!expectedAddOnPrincipal) {
|
||||
return { ok: false, reason: "missing add-on principal binding" };
|
||||
}
|
||||
const tokenPrincipal = normalizeLowercaseStringOrEmpty(payload?.sub ?? "");
|
||||
if (!tokenPrincipal || tokenPrincipal !== expectedAddOnPrincipal) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `unexpected add-on principal: ${tokenPrincipal || "<missing>"}`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : "invalid token" };
|
||||
}
|
||||
}
|
||||
|
||||
if (audienceType === "project-number") {
|
||||
try {
|
||||
const verifyClient = await getVerifyClient();
|
||||
const certs = await fetchChatCerts();
|
||||
await verifyClient.verifySignedJwtWithCertsAsync(bearer, certs, audience, [CHAT_ISSUER]);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : "invalid token" };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, reason: "unsupported audience type" };
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
resetGoogleChatAuthForTests(): void {
|
||||
authCache.clear();
|
||||
cachedCerts = null;
|
||||
verifyClientPromise = null;
|
||||
googleAuthRuntimeTesting.resetGoogleAuthRuntimeForTests();
|
||||
},
|
||||
};
|
||||
export { testing as __testing };
|
||||
123
extensions/googlechat/src/channel-base.ts
Normal file
123
extensions/googlechat/src/channel-base.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// Googlechat plugin module implements channel base behavior.
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
createScopedChannelConfigAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
type GoogleChatConfigAccessorAccount,
|
||||
listGoogleChatAccountIds,
|
||||
resolveDefaultGoogleChatAccountId,
|
||||
resolveGoogleChatConfigAccessorAccount,
|
||||
resolveGoogleChatAccount,
|
||||
type ResolvedGoogleChatAccount,
|
||||
} from "./accounts.js";
|
||||
import { googlechatSetupAdapter } from "./setup-core.js";
|
||||
import { googlechatSetupWizard } from "./setup-surface.js";
|
||||
|
||||
export const GOOGLECHAT_CHANNEL_ID = "googlechat" as const;
|
||||
|
||||
export const googlechatMeta = {
|
||||
id: GOOGLECHAT_CHANNEL_ID,
|
||||
label: "Google Chat",
|
||||
selectionLabel: "Google Chat (Chat API)",
|
||||
docsPath: "/channels/googlechat",
|
||||
docsLabel: "googlechat",
|
||||
blurb: "Google Workspace Chat app with HTTP webhook.",
|
||||
aliases: ["gchat", "google-chat"],
|
||||
order: 55,
|
||||
detailLabel: "Google Chat",
|
||||
systemImage: "message.badge",
|
||||
markdownCapable: true,
|
||||
};
|
||||
|
||||
export const formatGoogleChatAllowFromEntry = (entry: string) =>
|
||||
normalizeLowercaseStringOrEmpty(
|
||||
entry
|
||||
.trim()
|
||||
.replace(/^(googlechat|google-chat|gchat):/i, "")
|
||||
.replace(/^user:/i, "")
|
||||
.replace(/^users\//i, ""),
|
||||
);
|
||||
|
||||
const googleChatConfigAdapter = createScopedChannelConfigAdapter<
|
||||
ResolvedGoogleChatAccount,
|
||||
GoogleChatConfigAccessorAccount
|
||||
>({
|
||||
sectionKey: GOOGLECHAT_CHANNEL_ID,
|
||||
listAccountIds: listGoogleChatAccountIds,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount),
|
||||
resolveAccessorAccount: resolveGoogleChatConfigAccessorAccount,
|
||||
defaultAccountId: resolveDefaultGoogleChatAccountId,
|
||||
clearBaseFields: [
|
||||
"serviceAccount",
|
||||
"serviceAccountFile",
|
||||
"audienceType",
|
||||
"audience",
|
||||
"webhookPath",
|
||||
"webhookUrl",
|
||||
"botUser",
|
||||
"name",
|
||||
],
|
||||
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
|
||||
formatAllowFrom: (allowFrom) =>
|
||||
formatNormalizedAllowFromEntries({
|
||||
allowFrom,
|
||||
normalizeEntry: formatGoogleChatAllowFromEntry,
|
||||
}),
|
||||
resolveDefaultTo: (account) => account.config.defaultTo,
|
||||
});
|
||||
|
||||
type GoogleChatPluginBase = Pick<
|
||||
ChannelPlugin<ResolvedGoogleChatAccount>,
|
||||
| "id"
|
||||
| "meta"
|
||||
| "setup"
|
||||
| "setupWizard"
|
||||
| "capabilities"
|
||||
| "streaming"
|
||||
| "reload"
|
||||
| "configSchema"
|
||||
| "config"
|
||||
>;
|
||||
|
||||
export function createGoogleChatPluginBase(
|
||||
params: {
|
||||
configSchema?: ChannelPlugin<ResolvedGoogleChatAccount>["configSchema"];
|
||||
} = {},
|
||||
): GoogleChatPluginBase {
|
||||
return {
|
||||
id: GOOGLECHAT_CHANNEL_ID,
|
||||
meta: { ...googlechatMeta },
|
||||
setup: googlechatSetupAdapter,
|
||||
setupWizard: googlechatSetupWizard,
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "thread"],
|
||||
reactions: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
reload: { configPrefixes: ["channels.googlechat"] },
|
||||
...(params.configSchema ? { configSchema: params.configSchema } : {}),
|
||||
config: {
|
||||
...googleChatConfigAdapter,
|
||||
isConfigured: (account) => account.credentialSource !== "none",
|
||||
describeAccount: (account) =>
|
||||
describeAccountSnapshot({
|
||||
account,
|
||||
configured: account.credentialSource !== "none",
|
||||
extra: {
|
||||
credentialSource: account.credentialSource,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
174
extensions/googlechat/src/channel-config.test.ts
Normal file
174
extensions/googlechat/src/channel-config.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// Googlechat tests cover channel config plugin behavior.
|
||||
import type { ChannelOutboundPayloadHint } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearGoogleChatApprovalCardBindingsForTest,
|
||||
registerGoogleChatApprovalCardBinding,
|
||||
} from "./approval-card-actions.js";
|
||||
import { googlechatPlugin } from "./channel.js";
|
||||
import { googlechatSetupPlugin } from "./channel.setup.js";
|
||||
|
||||
describe("googlechatPlugin config adapter", () => {
|
||||
beforeEach(() => {
|
||||
clearGoogleChatApprovalCardBindingsForTest();
|
||||
});
|
||||
|
||||
it("keeps setup metadata aligned with the runtime plugin", () => {
|
||||
expect(googlechatSetupPlugin.id).toBe(googlechatPlugin.id);
|
||||
expect(googlechatSetupPlugin.meta).toEqual(googlechatPlugin.meta);
|
||||
expect(googlechatSetupPlugin.capabilities?.chatTypes).toEqual(
|
||||
googlechatPlugin.capabilities?.chatTypes,
|
||||
);
|
||||
});
|
||||
|
||||
it("registers an exec-capable native approval runtime", () => {
|
||||
expect(googlechatPlugin.approvalCapability?.nativeRuntime?.eventKinds).toContain("exec");
|
||||
});
|
||||
|
||||
it("keeps read-only accessors from resolving service account SecretRefs", () => {
|
||||
const cfg = {
|
||||
secrets: {
|
||||
providers: {
|
||||
google_chat_service_account: {
|
||||
source: "file",
|
||||
path: "/tmp/openclaw-missing-google-chat-service-account",
|
||||
mode: "singleValue",
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
source: "file",
|
||||
provider: "google_chat_service_account",
|
||||
id: "value",
|
||||
},
|
||||
dm: {
|
||||
allowFrom: ["users/123"],
|
||||
},
|
||||
defaultTo: "spaces/AAA",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(googlechatPlugin.config.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual([
|
||||
"users/123",
|
||||
]);
|
||||
expect(googlechatPlugin.config.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe(
|
||||
"spaces/AAA",
|
||||
);
|
||||
});
|
||||
|
||||
it("wires native exec approval suppression through the outbound adapter", () => {
|
||||
const cfg = {
|
||||
approvals: { exec: { enabled: true } },
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
client_email: "bot@example.com",
|
||||
private_key: "test-key",
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
audienceType: "app-url",
|
||||
audience: "https://chat-app.example.test/googlechat",
|
||||
dm: { allowFrom: ["users/123"] },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const payload: ReplyPayload = {
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "12345678-1234-1234-1234-123456789012",
|
||||
approvalSlug: "12345678",
|
||||
approvalKind: "exec",
|
||||
agentId: "dev",
|
||||
sessionKey: "agent:dev:main",
|
||||
},
|
||||
},
|
||||
};
|
||||
const hint: ChannelOutboundPayloadHint = {
|
||||
kind: "approval-pending",
|
||||
approvalKind: "exec",
|
||||
nativeRouteActive: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
googlechatPlugin.outbound?.shouldSuppressLocalPayloadPrompt?.({
|
||||
cfg,
|
||||
payload,
|
||||
hint,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("drops duplicate manual exec approval follow-up text after a native card is registered", () => {
|
||||
const approvalId = "12345678-1234-1234-1234-123456789012";
|
||||
registerGoogleChatApprovalCardBinding({
|
||||
token: "token-1",
|
||||
accountId: "default",
|
||||
approvalId,
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
spaceName: "spaces/AAA",
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
const payload: ReplyPayload = {
|
||||
text: `I need approval.\nReply with:\n/approve ${approvalId.slice(0, 8)} allow-once`,
|
||||
};
|
||||
|
||||
expect(
|
||||
googlechatPlugin.outbound?.normalizePayload?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
payload,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps unrelated or sendable structured approval-looking payloads visible", () => {
|
||||
const approvalId = "12345678-1234-1234-1234-123456789012";
|
||||
registerGoogleChatApprovalCardBinding({
|
||||
token: "token-1",
|
||||
accountId: "default",
|
||||
approvalId,
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
spaceName: "spaces/AAA",
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
const unrelatedPayload: ReplyPayload = { text: "/approve deadbeef allow-once" };
|
||||
const metadataPayload: ReplyPayload = {
|
||||
text: `/approve ${approvalId.slice(0, 8)} allow-once`,
|
||||
channelData: { execApproval: { approvalId } },
|
||||
};
|
||||
const structuredPayload: ReplyPayload = {
|
||||
text: `/approve ${approvalId.slice(0, 8)} allow-once`,
|
||||
presentation: { blocks: [] },
|
||||
};
|
||||
|
||||
expect(
|
||||
googlechatPlugin.outbound?.normalizePayload?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
payload: unrelatedPayload,
|
||||
}),
|
||||
).toBe(unrelatedPayload);
|
||||
expect(
|
||||
googlechatPlugin.outbound?.normalizePayload?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
payload: metadataPayload,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
googlechatPlugin.outbound?.normalizePayload?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
payload: structuredPayload,
|
||||
}),
|
||||
).toBe(structuredPayload);
|
||||
});
|
||||
});
|
||||
366
extensions/googlechat/src/channel.adapters.ts
Normal file
366
extensions/googlechat/src/channel.adapters.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
// Googlechat plugin module implements channel.adapters behavior.
|
||||
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import type {
|
||||
ChannelThreadingContext,
|
||||
ChannelThreadingToolContext,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
defineChannelMessageAdapter,
|
||||
type MessageReceiptPartKind,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { sanitizeForPlainText } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
composeAccountWarningCollectors,
|
||||
createAllowlistProviderOpenWarningCollector,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import {
|
||||
createChannelDirectoryAdapter,
|
||||
listResolvedDirectoryGroupEntriesFromMapKeys,
|
||||
listResolvedDirectoryUserEntriesFromAllowFrom,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type { OutboundMediaLoadOptions } from "openclaw/plugin-sdk/outbound-media";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
|
||||
import { shouldSuppressGoogleChatManualExecApprovalFollowupPayload } from "./approval-card-actions.js";
|
||||
import { formatGoogleChatAllowFromEntry } from "./channel-base.js";
|
||||
import {
|
||||
type ResolvedGoogleChatAccount,
|
||||
chunkTextForOutbound,
|
||||
readRemoteMediaBuffer,
|
||||
isGoogleChatUserTarget,
|
||||
loadOutboundMediaFromUrl,
|
||||
missingTargetError,
|
||||
normalizeGoogleChatTarget,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveChannelMediaMaxBytes,
|
||||
resolveGoogleChatAccount,
|
||||
resolveGoogleChatOutboundSpace,
|
||||
type OpenClawConfig,
|
||||
} from "./channel.deps.runtime.js";
|
||||
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
|
||||
|
||||
const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(
|
||||
() => import("./channel.runtime.js"),
|
||||
"googleChatChannelRuntime",
|
||||
);
|
||||
|
||||
function createGoogleChatSendReceipt(params: {
|
||||
messageId?: string;
|
||||
chatId: string;
|
||||
kind: MessageReceiptPartKind;
|
||||
}) {
|
||||
const messageId = params.messageId?.trim();
|
||||
return createMessageReceiptFromOutboundResults({
|
||||
results: messageId
|
||||
? [
|
||||
{
|
||||
channel: "googlechat",
|
||||
messageId,
|
||||
chatId: params.chatId,
|
||||
conversationId: params.chatId,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
threadId: params.chatId,
|
||||
kind: params.kind,
|
||||
});
|
||||
}
|
||||
|
||||
const collectGoogleChatGroupPolicyWarnings =
|
||||
createAllowlistProviderOpenWarningCollector<ResolvedGoogleChatAccount>({
|
||||
providerConfigPresent: (cfg) => cfg.channels?.googlechat !== undefined,
|
||||
resolveGroupPolicy: (account) => account.config.groupPolicy,
|
||||
buildOpenWarning: {
|
||||
surface: "Google Chat spaces",
|
||||
openBehavior: "allows any space to trigger (mention-gated)",
|
||||
remediation:
|
||||
'Set channels.googlechat.groupPolicy="allowlist" and configure channels.googlechat.groups',
|
||||
},
|
||||
});
|
||||
|
||||
const collectGoogleChatSecurityWarnings = composeAccountWarningCollectors<
|
||||
ResolvedGoogleChatAccount,
|
||||
{
|
||||
cfg: OpenClawConfig;
|
||||
account: ResolvedGoogleChatAccount;
|
||||
}
|
||||
>(
|
||||
collectGoogleChatGroupPolicyWarnings,
|
||||
(account) =>
|
||||
account.config.dm?.policy === "open" &&
|
||||
'- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".',
|
||||
);
|
||||
|
||||
export const googlechatGroupsAdapter = {
|
||||
resolveRequireMention: resolveGoogleChatGroupRequireMention,
|
||||
};
|
||||
|
||||
export const googlechatDirectoryAdapter = createChannelDirectoryAdapter({
|
||||
listPeers: async (params) =>
|
||||
listResolvedDirectoryUserEntriesFromAllowFrom<ResolvedGoogleChatAccount>({
|
||||
...params,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount),
|
||||
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
|
||||
normalizeId: (entry) => normalizeGoogleChatTarget(entry) ?? entry,
|
||||
}),
|
||||
listGroups: async (params) =>
|
||||
listResolvedDirectoryGroupEntriesFromMapKeys<ResolvedGoogleChatAccount>({
|
||||
...params,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount),
|
||||
resolveGroups: (account) => account.config.groups,
|
||||
}),
|
||||
});
|
||||
|
||||
export const googlechatSecurityAdapter = {
|
||||
dm: {
|
||||
channelKey: "googlechat",
|
||||
resolvePolicy: (account: ResolvedGoogleChatAccount) => account.config.dm?.policy,
|
||||
resolveAllowFrom: (account: ResolvedGoogleChatAccount) => account.config.dm?.allowFrom,
|
||||
allowFromPathSuffix: "dm.",
|
||||
normalizeEntry: (raw: string) => formatGoogleChatAllowFromEntry(raw),
|
||||
},
|
||||
collectWarnings: collectGoogleChatSecurityWarnings,
|
||||
};
|
||||
|
||||
export const googlechatThreadingAdapter = {
|
||||
scopedAccountReplyToMode: {
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
|
||||
resolveGoogleChatAccount({ cfg, accountId }),
|
||||
resolveReplyToMode: (account: ResolvedGoogleChatAccount, _chatType?: string | null) =>
|
||||
account.config.replyToMode,
|
||||
fallback: "off" as const,
|
||||
},
|
||||
buildToolContext: ({
|
||||
cfg,
|
||||
accountId,
|
||||
context,
|
||||
hasRepliedRef,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
context: ChannelThreadingContext;
|
||||
hasRepliedRef?: { value: boolean };
|
||||
}): ChannelThreadingToolContext => {
|
||||
const currentChannelId = normalizeGoogleChatTarget(context.To);
|
||||
const replyToId =
|
||||
normalizeOptionalString(context.ReplyToIdFull) ?? normalizeOptionalString(context.ReplyToId);
|
||||
|
||||
return {
|
||||
currentChannelId,
|
||||
currentMessageId: replyToId,
|
||||
currentThreadTs: replyToId,
|
||||
replyToMode: resolveGoogleChatAccount({ cfg, accountId }).config.replyToMode,
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const googlechatPairingTextAdapter = {
|
||||
idLabel: "googlechatUserId",
|
||||
message: PAIRING_APPROVED_MESSAGE,
|
||||
normalizeAllowEntry: (entry: string) => formatGoogleChatAllowFromEntry(entry),
|
||||
notify: async ({
|
||||
cfg,
|
||||
id,
|
||||
message,
|
||||
accountId,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
id: string;
|
||||
message: string;
|
||||
accountId?: string | null;
|
||||
}) => {
|
||||
const account = resolveGoogleChatAccount({ cfg, accountId });
|
||||
if (account.credentialSource === "none") {
|
||||
return;
|
||||
}
|
||||
const user = normalizeGoogleChatTarget(id) ?? id;
|
||||
const target = isGoogleChatUserTarget(user) ? user : `users/${user}`;
|
||||
const space = await resolveGoogleChatOutboundSpace({ account, target });
|
||||
const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime();
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text: message,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const googlechatOutboundAdapter = {
|
||||
base: {
|
||||
deliveryMode: "direct" as const,
|
||||
chunker: chunkTextForOutbound,
|
||||
chunkerMode: "markdown" as const,
|
||||
textChunkLimit: 4000,
|
||||
// Google Chat's plain-text pass does not remove assistant scaffolding.
|
||||
// Run the canonical delivery sanitizer first so internal tool traces are
|
||||
// dropped before channel formatting.
|
||||
sanitizeText: ({ text }: { text: string }) =>
|
||||
sanitizeForPlainText(sanitizeAssistantVisibleText(text)),
|
||||
normalizePayload: ({ payload }: { payload: ReplyPayload }) =>
|
||||
shouldSuppressGoogleChatManualExecApprovalFollowupPayload(payload) ? null : payload,
|
||||
resolveTarget: ({ to }: { to?: string }) => {
|
||||
const trimmed = normalizeOptionalString(to) ?? "";
|
||||
|
||||
if (trimmed) {
|
||||
const normalized = normalizeGoogleChatTarget(trimmed);
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
|
||||
};
|
||||
}
|
||||
return { ok: true as const, to: normalized };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false as const,
|
||||
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
|
||||
};
|
||||
},
|
||||
},
|
||||
attachedResults: {
|
||||
channel: "googlechat" as const,
|
||||
sendText: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
text: string;
|
||||
accountId?: string | null;
|
||||
replyToId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
}) => {
|
||||
const account = resolveGoogleChatAccount({
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
|
||||
const thread =
|
||||
typeof threadId === "number" ? String(threadId) : (threadId ?? replyToId ?? undefined);
|
||||
const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime();
|
||||
const result = await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text,
|
||||
thread,
|
||||
});
|
||||
const messageId = result?.messageName ?? "";
|
||||
return {
|
||||
messageId,
|
||||
chatId: space,
|
||||
receipt: createGoogleChatSendReceipt({ messageId, chatId: space, kind: "text" }),
|
||||
};
|
||||
},
|
||||
sendMedia: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaAccess?: OutboundMediaLoadOptions["mediaAccess"];
|
||||
mediaLocalRoots?: OutboundMediaLoadOptions["mediaLocalRoots"];
|
||||
mediaReadFile?: OutboundMediaLoadOptions["mediaReadFile"];
|
||||
accountId?: string | null;
|
||||
replyToId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
}) => {
|
||||
if (!mediaUrl) {
|
||||
throw new Error("Google Chat mediaUrl is required.");
|
||||
}
|
||||
const account = resolveGoogleChatAccount({
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
|
||||
const thread =
|
||||
typeof threadId === "number" ? String(threadId) : (threadId ?? replyToId ?? undefined);
|
||||
const maxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: ({ cfg: cfgLocal, accountId: accountIdLocal }) =>
|
||||
(
|
||||
cfgLocal.channels?.googlechat as
|
||||
| { accounts?: Record<string, { mediaMaxMb?: number }>; mediaMaxMb?: number }
|
||||
| undefined
|
||||
)?.accounts?.[accountIdLocal]?.mediaMaxMb ??
|
||||
(cfgLocal.channels?.googlechat as { mediaMaxMb?: number } | undefined)?.mediaMaxMb,
|
||||
accountId,
|
||||
});
|
||||
const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||
const loaded = /^https?:\/\//i.test(mediaUrl)
|
||||
? await readRemoteMediaBuffer({
|
||||
url: mediaUrl,
|
||||
maxBytes: effectiveMaxBytes,
|
||||
})
|
||||
: await loadOutboundMediaFromUrl(mediaUrl, {
|
||||
maxBytes: effectiveMaxBytes,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
});
|
||||
const { sendGoogleChatMessage, uploadGoogleChatAttachment } =
|
||||
await loadGoogleChatChannelRuntime();
|
||||
const upload = await uploadGoogleChatAttachment({
|
||||
account,
|
||||
space,
|
||||
filename: loaded.fileName ?? "attachment",
|
||||
buffer: loaded.buffer,
|
||||
contentType: loaded.contentType,
|
||||
});
|
||||
const result = await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text,
|
||||
thread,
|
||||
attachments: upload.attachmentUploadToken
|
||||
? [
|
||||
{
|
||||
attachmentUploadToken: upload.attachmentUploadToken,
|
||||
contentName: loaded.fileName,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
const messageId = result?.messageName ?? "";
|
||||
return {
|
||||
messageId,
|
||||
chatId: space,
|
||||
receipt: createGoogleChatSendReceipt({ messageId, chatId: space, kind: "media" }),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const googlechatMessageAdapter = defineChannelMessageAdapter({
|
||||
id: "googlechat",
|
||||
durableFinal: {
|
||||
capabilities: {
|
||||
text: true,
|
||||
media: true,
|
||||
thread: true,
|
||||
messageSendingHooks: true,
|
||||
},
|
||||
},
|
||||
send: {
|
||||
text: googlechatOutboundAdapter.attachedResults.sendText,
|
||||
media: googlechatOutboundAdapter.attachedResults.sendMedia,
|
||||
},
|
||||
});
|
||||
30
extensions/googlechat/src/channel.deps.runtime.ts
Normal file
30
extensions/googlechat/src/channel.deps.runtime.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Googlechat plugin module implements channeleps behavior.
|
||||
export {
|
||||
buildChannelConfigSchema,
|
||||
chunkTextForOutbound,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
readRemoteMediaBuffer,
|
||||
GoogleChatConfigSchema,
|
||||
loadOutboundMediaFromUrl,
|
||||
missingTargetError,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveChannelMediaMaxBytes,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelStatusIssue,
|
||||
type OpenClawConfig,
|
||||
} from "../runtime-api.js";
|
||||
export {
|
||||
type GoogleChatConfigAccessorAccount,
|
||||
listGoogleChatAccountIds,
|
||||
resolveGoogleChatConfigAccessorAccount,
|
||||
resolveDefaultGoogleChatAccountId,
|
||||
resolveGoogleChatAccount,
|
||||
type ResolvedGoogleChatAccount,
|
||||
} from "./accounts.js";
|
||||
export {
|
||||
isGoogleChatSpaceTarget,
|
||||
isGoogleChatUserTarget,
|
||||
normalizeGoogleChatTarget,
|
||||
resolveGoogleChatOutboundSpace,
|
||||
} from "./targets.js";
|
||||
18
extensions/googlechat/src/channel.runtime.ts
Normal file
18
extensions/googlechat/src/channel.runtime.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Googlechat plugin module implements channel behavior.
|
||||
import {
|
||||
probeGoogleChat as probeGoogleChatImpl,
|
||||
sendGoogleChatMessage as sendGoogleChatMessageImpl,
|
||||
uploadGoogleChatAttachment as uploadGoogleChatAttachmentImpl,
|
||||
} from "./api.js";
|
||||
import {
|
||||
resolveGoogleChatWebhookPath as resolveGoogleChatWebhookPathImpl,
|
||||
startGoogleChatMonitor as startGoogleChatMonitorImpl,
|
||||
} from "./monitor.js";
|
||||
|
||||
export const googleChatChannelRuntime = {
|
||||
probeGoogleChat: probeGoogleChatImpl,
|
||||
sendGoogleChatMessage: sendGoogleChatMessageImpl,
|
||||
uploadGoogleChatAttachment: uploadGoogleChatAttachmentImpl,
|
||||
resolveGoogleChatWebhookPath: resolveGoogleChatWebhookPathImpl,
|
||||
startGoogleChatMonitor: startGoogleChatMonitorImpl,
|
||||
};
|
||||
7
extensions/googlechat/src/channel.setup.ts
Normal file
7
extensions/googlechat/src/channel.setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Googlechat plugin module implements channel.setup behavior.
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { createGoogleChatPluginBase } from "./channel-base.js";
|
||||
|
||||
export const googlechatSetupPlugin: ChannelPlugin<ResolvedGoogleChatAccount> =
|
||||
createGoogleChatPluginBase();
|
||||
863
extensions/googlechat/src/channel.test.ts
Normal file
863
extensions/googlechat/src/channel.test.ts
Normal file
@@ -0,0 +1,863 @@
|
||||
// Googlechat tests cover channel plugin behavior.
|
||||
import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
createDirectoryTestRuntime,
|
||||
expectDirectorySurface,
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
googlechatDirectoryAdapter,
|
||||
googlechatMessageAdapter,
|
||||
googlechatOutboundAdapter,
|
||||
googlechatPairingTextAdapter,
|
||||
googlechatSecurityAdapter,
|
||||
googlechatThreadingAdapter,
|
||||
} from "./channel.adapters.js";
|
||||
|
||||
const uploadGoogleChatAttachmentMock = vi.hoisted(() => vi.fn());
|
||||
const sendGoogleChatMessageMock = vi.hoisted(() => vi.fn());
|
||||
const resolveGoogleChatAccountMock = vi.hoisted(() => vi.fn());
|
||||
const resolveGoogleChatOutboundSpaceMock = vi.hoisted(() => vi.fn());
|
||||
const readRemoteMediaBufferMock = vi.hoisted(() => vi.fn());
|
||||
const loadOutboundMediaFromUrlMock = vi.hoisted(() => vi.fn());
|
||||
const probeGoogleChatMock = vi.hoisted(() => vi.fn());
|
||||
const startGoogleChatMonitorMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
function normalizeGoogleChatTarget(raw?: string | null): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, "");
|
||||
const normalized = withoutPrefix
|
||||
.replace(/^user:(users\/)?/i, "users/")
|
||||
.replace(/^space:(spaces\/)?/i, "spaces/");
|
||||
if (normalized.toLowerCase().startsWith("users/")) {
|
||||
const suffix = normalized.slice("users/".length);
|
||||
return suffix.includes("@") ? `users/${suffix.toLowerCase()}` : normalized;
|
||||
}
|
||||
if (normalized.toLowerCase().startsWith("spaces/")) {
|
||||
return normalized;
|
||||
}
|
||||
if (normalized.includes("@")) {
|
||||
return `users/${normalized.toLowerCase()}`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveGoogleChatAccountImpl(params: { cfg: OpenClawConfig; accountId?: string | null }) {
|
||||
const accountId = params.accountId?.trim() || DEFAULT_ACCOUNT_ID;
|
||||
const channelConfig = (params.cfg.channels?.googlechat ?? {}) as Record<string, unknown>;
|
||||
const accounts =
|
||||
(channelConfig.accounts as Record<string, Record<string, unknown>> | undefined) ?? {};
|
||||
const scoped = accountId === DEFAULT_ACCOUNT_ID ? {} : (accounts[accountId] ?? {});
|
||||
const config = { ...channelConfig, ...scoped } as Record<string, unknown>;
|
||||
const serviceAccount = config.serviceAccount;
|
||||
return {
|
||||
accountId,
|
||||
name: typeof config.name === "string" ? config.name : undefined,
|
||||
enabled: channelConfig.enabled !== false && scoped.enabled !== false,
|
||||
config,
|
||||
credentialSource: serviceAccount ? ("inline" as const) : ("none" as const),
|
||||
};
|
||||
}
|
||||
|
||||
function mockGoogleChatOutboundSpaceResolution() {
|
||||
resolveGoogleChatOutboundSpaceMock.mockImplementation(async ({ target }: { target: string }) => {
|
||||
const normalized = normalizeGoogleChatTarget(target);
|
||||
if (!normalized) {
|
||||
throw new Error("Missing Google Chat target.");
|
||||
}
|
||||
return normalized.toLowerCase().startsWith("users/")
|
||||
? `spaces/DM-${normalized.slice("users/".length)}`
|
||||
: normalized.replace(/\/messages\/.+$/, "");
|
||||
});
|
||||
}
|
||||
|
||||
function mockGoogleChatMediaLoaders() {
|
||||
loadOutboundMediaFromUrlMock.mockImplementation(async (mediaUrl: string) => ({
|
||||
buffer: Buffer.from("default-bytes"),
|
||||
fileName: mediaUrl.split("/").pop() || "attachment",
|
||||
contentType: "application/octet-stream",
|
||||
}));
|
||||
readRemoteMediaBufferMock.mockImplementation(async () => ({
|
||||
buffer: Buffer.from("remote-bytes"),
|
||||
fileName: "remote.png",
|
||||
contentType: "image/png",
|
||||
}));
|
||||
}
|
||||
|
||||
vi.mock("./channel.runtime.js", () => {
|
||||
return {
|
||||
googleChatChannelRuntime: {
|
||||
probeGoogleChat: (...args: unknown[]) => probeGoogleChatMock(...args),
|
||||
resolveGoogleChatWebhookPath: () => "/googlechat/webhook",
|
||||
sendGoogleChatMessage: (...args: unknown[]) => sendGoogleChatMessageMock(...args),
|
||||
startGoogleChatMonitor: (...args: unknown[]) => startGoogleChatMonitorMock(...args),
|
||||
uploadGoogleChatAttachment: (...args: unknown[]) => uploadGoogleChatAttachmentMock(...args),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./channel.deps.runtime.js", () => {
|
||||
return {
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
GoogleChatConfigSchema: {},
|
||||
buildChannelConfigSchema: () => ({}),
|
||||
chunkTextForOutbound: (text: string, maxChars: number) => {
|
||||
const chunks: string[] = [];
|
||||
let current = "";
|
||||
for (const word of text.split(/\s+/)) {
|
||||
if (!word) {
|
||||
continue;
|
||||
}
|
||||
const next = current ? `${current} ${word}` : word;
|
||||
if (current && next.length > maxChars) {
|
||||
chunks.push(current);
|
||||
current = word;
|
||||
continue;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
if (current) {
|
||||
chunks.push(current);
|
||||
}
|
||||
return chunks;
|
||||
},
|
||||
createAccountStatusSink: () => () => {},
|
||||
readRemoteMediaBuffer: (...args: unknown[]) => readRemoteMediaBufferMock(...args),
|
||||
getChatChannelMeta: (id: string) => ({ id, name: id }),
|
||||
isGoogleChatSpaceTarget: (value: string) => value.toLowerCase().startsWith("spaces/"),
|
||||
isGoogleChatUserTarget: (value: string) => value.toLowerCase().startsWith("users/"),
|
||||
listGoogleChatAccountIds: (cfg: OpenClawConfig) => {
|
||||
const ids = Object.keys(cfg.channels?.googlechat?.accounts ?? {});
|
||||
return ids.length > 0 ? ids : ["default"];
|
||||
},
|
||||
loadOutboundMediaFromUrl: (...args: unknown[]) => loadOutboundMediaFromUrlMock(...args),
|
||||
missingTargetError: (channel: string, hint: string) =>
|
||||
new Error(`${channel} target is required (${hint})`),
|
||||
normalizeGoogleChatTarget,
|
||||
PAIRING_APPROVED_MESSAGE: "approved",
|
||||
resolveChannelMediaMaxBytes: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
resolveChannelLimitMb: (args: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
}) => number | undefined;
|
||||
accountId?: string;
|
||||
}) => {
|
||||
const limitMb = params.resolveChannelLimitMb({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
return typeof limitMb === "number" ? limitMb * 1024 * 1024 : undefined;
|
||||
},
|
||||
resolveDefaultGoogleChatAccountId: () => "default",
|
||||
resolveGoogleChatAccount: (...args: Parameters<typeof resolveGoogleChatAccountImpl>) =>
|
||||
resolveGoogleChatAccountMock(...args),
|
||||
resolveGoogleChatOutboundSpace: (...args: unknown[]) =>
|
||||
resolveGoogleChatOutboundSpaceMock(...args),
|
||||
runPassiveAccountLifecycle: async (params: { start: () => Promise<unknown> }) =>
|
||||
await params.start(),
|
||||
};
|
||||
});
|
||||
|
||||
resolveGoogleChatAccountMock.mockImplementation(resolveGoogleChatAccountImpl);
|
||||
mockGoogleChatOutboundSpaceResolution();
|
||||
mockGoogleChatMediaLoaders();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveGoogleChatAccountMock.mockImplementation(resolveGoogleChatAccountImpl);
|
||||
mockGoogleChatOutboundSpaceResolution();
|
||||
mockGoogleChatMediaLoaders();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./channel.runtime.js");
|
||||
vi.doUnmock("./channel.deps.runtime.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
function createGoogleChatCfg(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
client_email: "bot@example.com",
|
||||
private_key: "test-key", // pragma: allowlist secret
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setupRuntimeMediaMocks(params: { loadFileName: string; loadBytes: string }) {
|
||||
const loadOutboundMediaFromUrl = vi.fn(async () => ({
|
||||
buffer: Buffer.from(params.loadBytes),
|
||||
fileName: params.loadFileName,
|
||||
contentType: "image/png",
|
||||
}));
|
||||
const readRemoteMediaBuffer = vi.fn(async () => ({
|
||||
buffer: Buffer.from("remote-bytes"),
|
||||
fileName: "remote.png",
|
||||
contentType: "image/png",
|
||||
}));
|
||||
|
||||
loadOutboundMediaFromUrlMock.mockImplementation(loadOutboundMediaFromUrl);
|
||||
readRemoteMediaBufferMock.mockImplementation(readRemoteMediaBuffer);
|
||||
|
||||
return { loadOutboundMediaFromUrl, readRemoteMediaBuffer };
|
||||
}
|
||||
|
||||
function requireMockArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0): unknown {
|
||||
const call = mock.mock.calls[callIndex];
|
||||
if (!call) {
|
||||
throw new Error(`expected mock call ${callIndex}`);
|
||||
}
|
||||
return call[argIndex];
|
||||
}
|
||||
|
||||
function requireMockArgs(mock: ReturnType<typeof vi.fn>, callIndex = 0): unknown[] {
|
||||
const call = mock.mock.calls[callIndex];
|
||||
if (!call) {
|
||||
throw new Error(`expected mock call ${callIndex}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
describe("googlechatPlugin outbound sendMedia", () => {
|
||||
it("declares message adapter durable text, media, and thread with receipt proofs", async () => {
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
});
|
||||
uploadGoogleChatAttachmentMock.mockResolvedValue({
|
||||
attachmentUploadToken: "token-1",
|
||||
});
|
||||
|
||||
const cfg = createGoogleChatCfg();
|
||||
|
||||
const proofs = await verifyChannelMessageAdapterCapabilityProofs({
|
||||
adapterName: "googlechat",
|
||||
adapter: googlechatMessageAdapter,
|
||||
proofs: {
|
||||
text: async () => {
|
||||
const result = await googlechatMessageAdapter.send?.text?.({
|
||||
cfg,
|
||||
to: "spaces/AAA",
|
||||
text: "hello",
|
||||
});
|
||||
expect(result?.receipt.parts[0]?.kind).toBe("text");
|
||||
expect(result?.receipt.platformMessageIds).toEqual(["spaces/AAA/messages/msg-1"]);
|
||||
},
|
||||
media: async () => {
|
||||
const result = await googlechatMessageAdapter.send?.media?.({
|
||||
cfg,
|
||||
to: "spaces/AAA",
|
||||
text: "image",
|
||||
mediaUrl: "https://example.com/img.png",
|
||||
});
|
||||
expect(result?.receipt.parts[0]?.kind).toBe("media");
|
||||
expect(result?.receipt.platformMessageIds).toEqual(["spaces/AAA/messages/msg-1"]);
|
||||
},
|
||||
thread: async () => {
|
||||
sendGoogleChatMessageMock.mockClear();
|
||||
await googlechatMessageAdapter.send?.text?.({
|
||||
cfg,
|
||||
to: "spaces/AAA",
|
||||
text: "threaded",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
const request = requireMockArg(sendGoogleChatMessageMock) as {
|
||||
space?: string;
|
||||
thread?: string;
|
||||
};
|
||||
expect(request.space).toBe("spaces/AAA");
|
||||
expect(request.thread).toBe("thread-1");
|
||||
},
|
||||
messageSendingHooks: () => {
|
||||
expect(googlechatMessageAdapter.send?.text).toBeTypeOf("function");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(proofs).toStrictEqual([
|
||||
{ capability: "text", status: "verified" },
|
||||
{ capability: "media", status: "verified" },
|
||||
{ capability: "poll", status: "not_declared" },
|
||||
{ capability: "payload", status: "not_declared" },
|
||||
{ capability: "silent", status: "not_declared" },
|
||||
{ capability: "replyTo", status: "not_declared" },
|
||||
{ capability: "thread", status: "verified" },
|
||||
{ capability: "nativeQuote", status: "not_declared" },
|
||||
{ capability: "messageSendingHooks", status: "verified" },
|
||||
{ capability: "batch", status: "not_declared" },
|
||||
{ capability: "reconcileUnknownSend", status: "not_declared" },
|
||||
{ capability: "afterSendSuccess", status: "not_declared" },
|
||||
{ capability: "afterCommit", status: "not_declared" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("chunks outbound text without requiring Google Chat runtime initialization", () => {
|
||||
const chunker = googlechatOutboundAdapter.base.chunker;
|
||||
|
||||
expect(chunker("alpha beta", 5)).toEqual(["alpha", "beta"]);
|
||||
});
|
||||
|
||||
it("loads local media with mediaLocalRoots via runtime media loader", async () => {
|
||||
const { loadOutboundMediaFromUrl, readRemoteMediaBuffer } = setupRuntimeMediaMocks({
|
||||
loadFileName: "image.png",
|
||||
loadBytes: "image-bytes",
|
||||
});
|
||||
|
||||
uploadGoogleChatAttachmentMock.mockResolvedValue({
|
||||
attachmentUploadToken: "token-1",
|
||||
});
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
});
|
||||
|
||||
const cfg = createGoogleChatCfg();
|
||||
|
||||
const result = await googlechatOutboundAdapter.attachedResults.sendMedia({
|
||||
cfg,
|
||||
to: "spaces/AAA",
|
||||
text: "caption",
|
||||
mediaUrl: "/tmp/workspace/image.png",
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
const [mediaUrl, mediaOptions] = requireMockArgs(loadOutboundMediaFromUrl) as [
|
||||
string,
|
||||
{ mediaLocalRoots?: string[] },
|
||||
];
|
||||
expect(mediaUrl).toBe("/tmp/workspace/image.png");
|
||||
expect(mediaOptions.mediaLocalRoots).toEqual(["/tmp/workspace"]);
|
||||
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
|
||||
const uploadRequest = requireMockArg(uploadGoogleChatAttachmentMock) as {
|
||||
space?: string;
|
||||
filename?: string;
|
||||
contentType?: string;
|
||||
};
|
||||
expect(uploadRequest.space).toBe("spaces/AAA");
|
||||
expect(uploadRequest.filename).toBe("image.png");
|
||||
expect(uploadRequest.contentType).toBe("image/png");
|
||||
const sendRequest = requireMockArg(sendGoogleChatMessageMock) as {
|
||||
space?: string;
|
||||
text?: string;
|
||||
};
|
||||
expect(sendRequest.space).toBe("spaces/AAA");
|
||||
expect(sendRequest.text).toBe("caption");
|
||||
expect(result.messageId).toBe("spaces/AAA/messages/msg-1");
|
||||
expect(result.chatId).toBe("spaces/AAA");
|
||||
expect(result.receipt.primaryPlatformMessageId).toBe("spaces/AAA/messages/msg-1");
|
||||
});
|
||||
|
||||
it("keeps remote URL media fetch on readRemoteMediaBuffer with maxBytes cap", async () => {
|
||||
const { loadOutboundMediaFromUrl, readRemoteMediaBuffer } = setupRuntimeMediaMocks({
|
||||
loadFileName: "unused.png",
|
||||
loadBytes: "should-not-be-used",
|
||||
});
|
||||
|
||||
uploadGoogleChatAttachmentMock.mockResolvedValue({
|
||||
attachmentUploadToken: "token-2",
|
||||
});
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-2",
|
||||
});
|
||||
|
||||
const cfg = createGoogleChatCfg();
|
||||
|
||||
const result = await googlechatOutboundAdapter.attachedResults.sendMedia({
|
||||
cfg,
|
||||
to: "spaces/AAA",
|
||||
text: "caption",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
const remoteRequest = requireMockArg(readRemoteMediaBuffer) as {
|
||||
url?: string;
|
||||
maxBytes?: number;
|
||||
};
|
||||
expect(remoteRequest.url).toBe("https://example.com/image.png");
|
||||
expect(remoteRequest.maxBytes).toBe(20 * 1024 * 1024);
|
||||
expect(loadOutboundMediaFromUrl).not.toHaveBeenCalled();
|
||||
const uploadRequest = requireMockArg(uploadGoogleChatAttachmentMock) as {
|
||||
space?: string;
|
||||
filename?: string;
|
||||
contentType?: string;
|
||||
};
|
||||
expect(uploadRequest.space).toBe("spaces/AAA");
|
||||
expect(uploadRequest.filename).toBe("remote.png");
|
||||
expect(uploadRequest.contentType).toBe("image/png");
|
||||
const sendRequest = requireMockArg(sendGoogleChatMessageMock) as {
|
||||
space?: string;
|
||||
text?: string;
|
||||
};
|
||||
expect(sendRequest.space).toBe("spaces/AAA");
|
||||
expect(sendRequest.text).toBe("caption");
|
||||
expect(result.messageId).toBe("spaces/AAA/messages/msg-2");
|
||||
expect(result.chatId).toBe("spaces/AAA");
|
||||
expect(result.receipt.primaryPlatformMessageId).toBe("spaces/AAA/messages/msg-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechatPlugin threading", () => {
|
||||
it("honors per-account replyToMode overrides", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
replyToMode: "all",
|
||||
accounts: {
|
||||
work: {
|
||||
replyToMode: "first",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const workAccount = googlechatThreadingAdapter.scopedAccountReplyToMode.resolveAccount(
|
||||
cfg,
|
||||
"work",
|
||||
);
|
||||
const defaultAccount = googlechatThreadingAdapter.scopedAccountReplyToMode.resolveAccount(
|
||||
cfg,
|
||||
"default",
|
||||
);
|
||||
|
||||
expect(
|
||||
googlechatThreadingAdapter.scopedAccountReplyToMode.resolveReplyToMode(workAccount),
|
||||
).toBe("first");
|
||||
expect(
|
||||
googlechatThreadingAdapter.scopedAccountReplyToMode.resolveReplyToMode(defaultAccount),
|
||||
).toBe("all");
|
||||
});
|
||||
|
||||
it("uses the inbound thread resource as the current tool reply target", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const hasRepliedRef = { value: false };
|
||||
|
||||
const context = googlechatThreadingAdapter.buildToolContext({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
context: {
|
||||
To: "googlechat:spaces/AAA",
|
||||
CurrentMessageId: "spaces/AAA/messages/msg-1",
|
||||
ReplyToId: "spaces/AAA/threads/thread-1",
|
||||
},
|
||||
hasRepliedRef,
|
||||
});
|
||||
|
||||
expect(context).toMatchObject({
|
||||
currentChannelId: "spaces/AAA",
|
||||
currentMessageId: "spaces/AAA/threads/thread-1",
|
||||
currentThreadTs: "spaces/AAA/threads/thread-1",
|
||||
replyToMode: "all",
|
||||
hasRepliedRef,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not use message resources as implicit Google Chat reply targets", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const context = googlechatThreadingAdapter.buildToolContext({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
context: {
|
||||
To: "googlechat:spaces/AAA",
|
||||
CurrentMessageId: "spaces/AAA/messages/msg-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(context).toMatchObject({
|
||||
currentChannelId: "spaces/AAA",
|
||||
replyToMode: "all",
|
||||
});
|
||||
expect(context.currentMessageId).toBeUndefined();
|
||||
expect(context.currentThreadTs).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
const resolveTarget = googlechatOutboundAdapter.base.resolveTarget;
|
||||
|
||||
describe("googlechatPlugin outbound resolveTarget", () => {
|
||||
it("resolves valid chat targets", () => {
|
||||
const result = resolveTarget({
|
||||
to: "spaces/AAA",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
expect(result.to).toBe("spaces/AAA");
|
||||
});
|
||||
|
||||
it("resolves email targets", () => {
|
||||
const result = resolveTarget({
|
||||
to: "user@example.com",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
expect(result.to).toBe("users/user@example.com");
|
||||
});
|
||||
|
||||
it("errors on invalid targets", () => {
|
||||
const result = resolveTarget({
|
||||
to: " ",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("Expected invalid target to fail");
|
||||
}
|
||||
expect(result.error.message).toBe(
|
||||
"Google Chat target is required (<spaces/{space}|users/{user}>)",
|
||||
);
|
||||
});
|
||||
|
||||
it("errors when no target is provided", () => {
|
||||
const result = resolveTarget({
|
||||
to: undefined,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("Expected missing target to fail");
|
||||
}
|
||||
expect(result.error.message).toBe(
|
||||
"Google Chat target is required (<spaces/{space}|users/{user}>)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechatPlugin outbound cfg threading", () => {
|
||||
it("preserves accountId when sending pairing approvals", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
work: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const account = {
|
||||
accountId: "work",
|
||||
config: {},
|
||||
credentialSource: "inline" as const,
|
||||
};
|
||||
resolveGoogleChatAccountMock.mockReturnValue(account);
|
||||
resolveGoogleChatOutboundSpaceMock.mockResolvedValue("spaces/WORK");
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/WORK/messages/msg-1",
|
||||
});
|
||||
|
||||
await googlechatPairingTextAdapter.notify({
|
||||
cfg: cfg as never,
|
||||
id: "user@example.com",
|
||||
message: googlechatPairingTextAdapter.message,
|
||||
accountId: "work",
|
||||
} as never);
|
||||
|
||||
expect(resolveGoogleChatAccountMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId: "work",
|
||||
});
|
||||
const request = requireMockArg(sendGoogleChatMessageMock) as {
|
||||
account?: unknown;
|
||||
space?: string;
|
||||
text?: string;
|
||||
};
|
||||
expect(request.account).toBe(account);
|
||||
expect(request.space).toBe("spaces/WORK");
|
||||
expect(request.text).toBe(googlechatPairingTextAdapter.message);
|
||||
});
|
||||
|
||||
it("threads resolved cfg into sendText account resolution", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const account = {
|
||||
accountId: "default",
|
||||
config: {},
|
||||
credentialSource: "inline" as const,
|
||||
};
|
||||
resolveGoogleChatAccountMock.mockReturnValue(account);
|
||||
resolveGoogleChatOutboundSpaceMock.mockResolvedValue("spaces/AAA");
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
});
|
||||
|
||||
await googlechatOutboundAdapter.attachedResults.sendText({
|
||||
cfg: cfg as never,
|
||||
to: "users/123",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolveGoogleChatAccountMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
});
|
||||
const request = requireMockArg(sendGoogleChatMessageMock) as {
|
||||
account?: unknown;
|
||||
space?: string;
|
||||
text?: string;
|
||||
};
|
||||
expect(request.account).toBe(account);
|
||||
expect(request.space).toBe("spaces/AAA");
|
||||
expect(request.text).toBe("hello");
|
||||
});
|
||||
|
||||
it("threads resolved cfg into sendMedia account and media loading path", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
},
|
||||
mediaMaxMb: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
const account = {
|
||||
accountId: "default",
|
||||
config: { mediaMaxMb: 20 },
|
||||
credentialSource: "inline" as const,
|
||||
};
|
||||
const { readRemoteMediaBuffer } = setupRuntimeMediaMocks({
|
||||
loadFileName: "unused.png",
|
||||
loadBytes: "should-not-be-used",
|
||||
});
|
||||
|
||||
resolveGoogleChatAccountMock.mockReturnValue(account);
|
||||
resolveGoogleChatOutboundSpaceMock.mockResolvedValue("spaces/AAA");
|
||||
uploadGoogleChatAttachmentMock.mockResolvedValue({
|
||||
attachmentUploadToken: "token-1",
|
||||
});
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-2",
|
||||
});
|
||||
|
||||
await googlechatOutboundAdapter.attachedResults.sendMedia({
|
||||
cfg: cfg as never,
|
||||
to: "users/123",
|
||||
text: "photo",
|
||||
mediaUrl: "https://example.com/file.png",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolveGoogleChatAccountMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
});
|
||||
const remoteRequest = requireMockArg(readRemoteMediaBuffer) as {
|
||||
url?: string;
|
||||
maxBytes?: number;
|
||||
};
|
||||
expect(remoteRequest.url).toBe("https://example.com/file.png");
|
||||
expect(remoteRequest.maxBytes).toBe(8 * 1024 * 1024);
|
||||
const uploadRequest = requireMockArg(uploadGoogleChatAttachmentMock) as {
|
||||
account?: unknown;
|
||||
space?: string;
|
||||
filename?: string;
|
||||
};
|
||||
expect(uploadRequest.account).toBe(account);
|
||||
expect(uploadRequest.space).toBe("spaces/AAA");
|
||||
expect(uploadRequest.filename).toBe("remote.png");
|
||||
const sendRequest = requireMockArg(sendGoogleChatMessageMock) as {
|
||||
account?: unknown;
|
||||
attachments?: Array<{ attachmentUploadToken: string; contentName: string }>;
|
||||
};
|
||||
expect(sendRequest.account).toBe(account);
|
||||
expect(sendRequest.attachments).toEqual([
|
||||
{ attachmentUploadToken: "token-1", contentName: "remote.png" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends media without requiring Google Chat runtime initialization", async () => {
|
||||
const { loadOutboundMediaFromUrl } = setupRuntimeMediaMocks({
|
||||
loadFileName: "image.png",
|
||||
loadBytes: "image-bytes",
|
||||
});
|
||||
|
||||
uploadGoogleChatAttachmentMock.mockResolvedValue({
|
||||
attachmentUploadToken: "token-cold",
|
||||
});
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-cold",
|
||||
});
|
||||
|
||||
const cfg = createGoogleChatCfg();
|
||||
|
||||
const result = await googlechatOutboundAdapter.attachedResults.sendMedia({
|
||||
cfg,
|
||||
to: "spaces/AAA",
|
||||
text: "caption",
|
||||
mediaUrl: "/tmp/workspace/image.png",
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
accountId: "default",
|
||||
});
|
||||
expect(result.messageId).toBe("spaces/AAA/messages/msg-cold");
|
||||
expect(result.chatId).toBe("spaces/AAA");
|
||||
|
||||
const [mediaUrl, mediaOptions] = requireMockArgs(loadOutboundMediaFromUrl) as [
|
||||
string,
|
||||
{ mediaLocalRoots?: string[] },
|
||||
];
|
||||
expect(mediaUrl).toBe("/tmp/workspace/image.png");
|
||||
expect(mediaOptions.mediaLocalRoots).toEqual(["/tmp/workspace"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechat directory", () => {
|
||||
const runtimeEnv = createDirectoryTestRuntime() as never;
|
||||
|
||||
it("lists peers and groups from config", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: { allowFrom: ["users/alice", "googlechat:bob"] },
|
||||
groups: {
|
||||
"spaces/AAA": {},
|
||||
"spaces/BBB": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const directory = expectDirectorySurface(googlechatDirectoryAdapter);
|
||||
|
||||
const peers = await directory.listPeers({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
});
|
||||
expect(peers).toStrictEqual([
|
||||
{ kind: "user", id: "users/alice" },
|
||||
{ kind: "user", id: "bob" },
|
||||
]);
|
||||
|
||||
const groups = await directory.listGroups({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
});
|
||||
expect(groups).toStrictEqual([
|
||||
{ kind: "group", id: "spaces/AAA" },
|
||||
{ kind: "group", id: "spaces/BBB" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes spaced provider-prefixed dm allowlist entries", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: { allowFrom: [" users/alice ", " googlechat:user:Bob@Example.com "] },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const directory = expectDirectorySurface(googlechatDirectoryAdapter);
|
||||
|
||||
const peers = await directory.listPeers({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
});
|
||||
expect(peers).toStrictEqual([
|
||||
{ kind: "user", id: "users/alice" },
|
||||
{ kind: "user", id: "users/bob@example.com" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechatPlugin security", () => {
|
||||
it("normalizes prefixed DM allowlist entries to lowercase user ids", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
allowFrom: [" googlechat:user:Bob@Example.com "],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const account = resolveGoogleChatAccountImpl({ cfg, accountId: "default" });
|
||||
|
||||
expect(googlechatSecurityAdapter.dm.resolvePolicy(account)).toBe("allowlist");
|
||||
expect(googlechatSecurityAdapter.dm.resolveAllowFrom(account)).toEqual([
|
||||
" googlechat:user:Bob@Example.com ",
|
||||
]);
|
||||
expect(googlechatSecurityAdapter.dm.normalizeEntry(" googlechat:user:Bob@Example.com ")).toBe(
|
||||
"bob@example.com",
|
||||
);
|
||||
expect(googlechatPairingTextAdapter.normalizeAllowEntry(" users/Alice@Example.com ")).toBe(
|
||||
"alice@example.com",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechatPlugin outbound sanitizeText", () => {
|
||||
const sanitizeText = googlechatOutboundAdapter.base.sanitizeText;
|
||||
|
||||
it("strips internal tool-trace failure banners from outbound text (#90684)", () => {
|
||||
const text =
|
||||
"Visible answer.\n⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed";
|
||||
const out = sanitizeText({ text });
|
||||
expect(out).toBe("Visible answer.");
|
||||
expect(out).not.toContain("failed");
|
||||
expect(out).not.toContain("🛠️");
|
||||
});
|
||||
|
||||
it("preserves ordinary assistant prose untouched", () => {
|
||||
const text = "El pipeline tiene 3 deals abiertos por USD 12.000.";
|
||||
expect(sanitizeText({ text })).toBe(text);
|
||||
});
|
||||
});
|
||||
214
extensions/googlechat/src/channel.ts
Normal file
214
extensions/googlechat/src/channel.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
// Googlechat plugin module implements channel behavior.
|
||||
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||
import {
|
||||
googleChatApprovalCapability,
|
||||
shouldSuppressLocalGoogleChatExecApprovalPrompt,
|
||||
} from "./approval-native.js";
|
||||
import { createGoogleChatPluginBase, GOOGLECHAT_CHANNEL_ID } from "./channel-base.js";
|
||||
import {
|
||||
googlechatDirectoryAdapter,
|
||||
googlechatGroupsAdapter,
|
||||
googlechatMessageAdapter,
|
||||
googlechatOutboundAdapter,
|
||||
googlechatPairingTextAdapter,
|
||||
googlechatSecurityAdapter,
|
||||
googlechatThreadingAdapter,
|
||||
} from "./channel.adapters.js";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
GoogleChatConfigSchema,
|
||||
isGoogleChatSpaceTarget,
|
||||
isGoogleChatUserTarget,
|
||||
listGoogleChatAccountIds,
|
||||
normalizeGoogleChatTarget,
|
||||
resolveGoogleChatAccount,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelStatusIssue,
|
||||
type ResolvedGoogleChatAccount,
|
||||
} from "./channel.deps.runtime.js";
|
||||
import {
|
||||
legacyConfigRules as GOOGLECHAT_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig as normalizeGoogleChatCompatibilityConfig,
|
||||
} from "./doctor-contract.js";
|
||||
import { collectGoogleChatMutableAllowlistWarnings } from "./doctor.js";
|
||||
import { startGoogleChatGatewayAccount } from "./gateway.js";
|
||||
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
|
||||
|
||||
const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(
|
||||
() => import("./channel.runtime.js"),
|
||||
"googleChatChannelRuntime",
|
||||
);
|
||||
|
||||
const googlechatActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: ({ cfg, accountId }) => {
|
||||
const accounts = accountId
|
||||
? [resolveGoogleChatAccount({ cfg, accountId })].filter(
|
||||
(account) => account.enabled && account.credentialSource !== "none",
|
||||
)
|
||||
: listGoogleChatAccountIds(cfg)
|
||||
.map((id) => resolveGoogleChatAccount({ cfg, accountId: id }))
|
||||
.filter((account) => account.enabled && account.credentialSource !== "none");
|
||||
if (accounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const actions = new Set<ChannelMessageActionName>(["send", "upload-file"]);
|
||||
if (accounts.some((account) => account.config.actions?.reactions !== false)) {
|
||||
actions.add("react");
|
||||
actions.add("reactions");
|
||||
}
|
||||
return { actions: Array.from(actions) };
|
||||
},
|
||||
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
|
||||
handleAction: async (ctx) => {
|
||||
const { googlechatMessageActions } = await import("./actions.js");
|
||||
if (!googlechatMessageActions.handleAction) {
|
||||
throw new Error("Google Chat actions are not available.");
|
||||
}
|
||||
return await googlechatMessageActions.handleAction(ctx);
|
||||
},
|
||||
};
|
||||
|
||||
export const googlechatPlugin = createChatChannelPlugin({
|
||||
base: {
|
||||
...createGoogleChatPluginBase({
|
||||
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
|
||||
}),
|
||||
approvalCapability: googleChatApprovalCapability,
|
||||
secrets: {
|
||||
secretTargetRegistryEntries,
|
||||
collectRuntimeConfigAssignments,
|
||||
},
|
||||
groups: googlechatGroupsAdapter,
|
||||
messaging: {
|
||||
targetPrefixes: ["googlechat", "google-chat", "gchat"],
|
||||
normalizeTarget: normalizeGoogleChatTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw, normalized) => {
|
||||
const value = normalized ?? raw.trim();
|
||||
return isGoogleChatSpaceTarget(value) || isGoogleChatUserTarget(value);
|
||||
},
|
||||
hint: "<spaces/{space}|users/{user}>",
|
||||
},
|
||||
},
|
||||
directory: googlechatDirectoryAdapter,
|
||||
message: googlechatMessageAdapter,
|
||||
resolver: {
|
||||
resolveTargets: async ({ inputs, kind }) => {
|
||||
const resolved = inputs.map((input) => {
|
||||
const normalized = normalizeGoogleChatTarget(input);
|
||||
if (!normalized) {
|
||||
return { input, resolved: false, note: "empty target" };
|
||||
}
|
||||
if (kind === "user" && isGoogleChatUserTarget(normalized)) {
|
||||
return { input, resolved: true, id: normalized };
|
||||
}
|
||||
if (kind === "group" && isGoogleChatSpaceTarget(normalized)) {
|
||||
return { input, resolved: true, id: normalized };
|
||||
}
|
||||
return {
|
||||
input,
|
||||
resolved: false,
|
||||
note: "use spaces/{space} or users/{user}",
|
||||
};
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
},
|
||||
actions: googlechatActions,
|
||||
doctor: {
|
||||
dmAllowFromMode: "nestedOnly",
|
||||
groupModel: "route",
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
warnOnEmptyGroupSenderAllowlist: false,
|
||||
legacyConfigRules: GOOGLECHAT_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig: normalizeGoogleChatCompatibilityConfig,
|
||||
collectMutableAllowlistWarnings: collectGoogleChatMutableAllowlistWarnings,
|
||||
},
|
||||
status: createComputedAccountStatusAdapter<ResolvedGoogleChatAccount>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
collectStatusIssues: (accounts): ChannelStatusIssue[] =>
|
||||
accounts.flatMap((entry) => {
|
||||
const accountId = entry.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const enabled = entry.enabled !== false;
|
||||
const configured = entry.configured === true;
|
||||
if (!enabled || !configured) {
|
||||
return [];
|
||||
}
|
||||
const issues: ChannelStatusIssue[] = [];
|
||||
if (!entry.audience) {
|
||||
issues.push({
|
||||
channel: GOOGLECHAT_CHANNEL_ID,
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "Google Chat audience is missing (set channels.googlechat.audience).",
|
||||
fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
|
||||
});
|
||||
}
|
||||
if (!entry.audienceType) {
|
||||
issues.push({
|
||||
channel: GOOGLECHAT_CHANNEL_ID,
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "Google Chat audienceType is missing (app-url or project-number).",
|
||||
fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}),
|
||||
buildChannelSummary: ({ snapshot }) =>
|
||||
buildPassiveProbedChannelStatusSummary(snapshot, {
|
||||
credentialSource: snapshot.credentialSource ?? "none",
|
||||
audienceType: snapshot.audienceType ?? null,
|
||||
audience: snapshot.audience ?? null,
|
||||
webhookPath: snapshot.webhookPath ?? null,
|
||||
webhookUrl: snapshot.webhookUrl ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account }) =>
|
||||
(await loadGoogleChatChannelRuntime()).probeGoogleChat(account),
|
||||
resolveAccountSnapshot: ({ account }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.credentialSource !== "none",
|
||||
extra: {
|
||||
credentialSource: account.credentialSource,
|
||||
audienceType: account.config.audienceType,
|
||||
audience: account.config.audience,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
dmPolicy: account.config.dm?.policy ?? "pairing",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
gateway: {
|
||||
startAccount: startGoogleChatGatewayAccount,
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
text: googlechatPairingTextAdapter,
|
||||
},
|
||||
security: googlechatSecurityAdapter,
|
||||
threading: googlechatThreadingAdapter,
|
||||
outbound: {
|
||||
...googlechatOutboundAdapter,
|
||||
base: {
|
||||
...googlechatOutboundAdapter.base,
|
||||
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload, hint }) =>
|
||||
shouldSuppressLocalGoogleChatExecApprovalPrompt({
|
||||
cfg,
|
||||
accountId,
|
||||
payload,
|
||||
hint,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
32
extensions/googlechat/src/config-schema.test.ts
Normal file
32
extensions/googlechat/src/config-schema.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Googlechat tests cover config schema plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GoogleChatConfigSchema } from "../runtime-api.js";
|
||||
|
||||
describe("googlechat config schema", () => {
|
||||
it("accepts serviceAccount refs", () => {
|
||||
const result = GoogleChatConfigSchema.safeParse({
|
||||
serviceAccountRef: {
|
||||
source: "file",
|
||||
provider: "filemain",
|
||||
id: "/channels/googlechat/serviceAccount",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the documented group config shape", () => {
|
||||
const result = GoogleChatConfigSchema.safeParse({
|
||||
groups: {
|
||||
"spaces/AAAA": {
|
||||
enabled: true,
|
||||
requireMention: true,
|
||||
users: ["users/1234567890"],
|
||||
systemPrompt: "Short answers only.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
4
extensions/googlechat/src/config-schema.ts
Normal file
4
extensions/googlechat/src/config-schema.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Googlechat helper module supports config schema behavior.
|
||||
import { buildChannelConfigSchema, GoogleChatConfigSchema } from "../config-api.js";
|
||||
|
||||
export const GoogleChatChannelConfigSchema = buildChannelConfigSchema(GoogleChatConfigSchema);
|
||||
76
extensions/googlechat/src/doctor-contract.test.ts
Normal file
76
extensions/googlechat/src/doctor-contract.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// Googlechat tests cover doctor contract plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeCompatibilityConfig } from "./doctor-contract.js";
|
||||
|
||||
describe("googlechat doctor contract", () => {
|
||||
it("removes legacy streamMode keys", () => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: {
|
||||
channels: {
|
||||
googlechat: {
|
||||
streamMode: "append",
|
||||
accounts: {
|
||||
work: {
|
||||
streamMode: "replace",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
"Removed channels.googlechat.streamMode (legacy key no longer used).",
|
||||
"Removed channels.googlechat.accounts.work.streamMode (legacy key no longer used).",
|
||||
]);
|
||||
expect(result.config.channels?.googlechat).toEqual({
|
||||
accounts: {
|
||||
work: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("moves legacy group allow toggles into enabled", () => {
|
||||
const result = normalizeCompatibilityConfig({
|
||||
cfg: {
|
||||
channels: {
|
||||
googlechat: {
|
||||
groups: {
|
||||
"spaces/aaa": {
|
||||
allow: false,
|
||||
},
|
||||
"spaces/bbb": {
|
||||
allow: true,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
groups: {
|
||||
"spaces/ccc": {
|
||||
allow: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
"Moved channels.googlechat.groups.spaces/aaa.allow → channels.googlechat.groups.spaces/aaa.enabled.",
|
||||
"Removed channels.googlechat.groups.spaces/bbb.allow (channels.googlechat.groups.spaces/bbb.enabled already set).",
|
||||
"Moved channels.googlechat.accounts.work.groups.spaces/ccc.allow → channels.googlechat.accounts.work.groups.spaces/ccc.enabled.",
|
||||
]);
|
||||
expect(result.config.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({
|
||||
enabled: false,
|
||||
});
|
||||
expect(result.config.channels?.googlechat?.groups?.["spaces/bbb"]).toEqual({
|
||||
enabled: false,
|
||||
});
|
||||
expect(result.config.channels?.googlechat?.accounts?.work?.groups?.["spaces/ccc"]).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
181
extensions/googlechat/src/doctor-contract.ts
Normal file
181
extensions/googlechat/src/doctor-contract.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// Googlechat plugin module implements doctor contract behavior.
|
||||
import type {
|
||||
ChannelDoctorConfigMutation,
|
||||
ChannelDoctorLegacyConfigRule,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { asObjectRecord } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
|
||||
type GoogleChatChannelsConfig = NonNullable<OpenClawConfig["channels"]>;
|
||||
|
||||
function hasLegacyGoogleChatStreamMode(value: unknown): boolean {
|
||||
return asObjectRecord(value)?.streamMode !== undefined;
|
||||
}
|
||||
|
||||
function hasLegacyGoogleChatGroupAllowAlias(value: unknown): boolean {
|
||||
const groups = asObjectRecord(asObjectRecord(value)?.groups);
|
||||
if (!groups) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(groups).some((group) => Object.hasOwn(asObjectRecord(group) ?? {}, "allow"));
|
||||
}
|
||||
|
||||
function hasLegacyAccountAliases(value: unknown, match: (entry: unknown) => boolean): boolean {
|
||||
const accounts = asObjectRecord(value);
|
||||
if (!accounts) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(accounts).some((account) => match(account));
|
||||
}
|
||||
|
||||
function normalizeGoogleChatGroups(params: {
|
||||
groups: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { groups: Record<string, unknown>; changed: boolean } {
|
||||
let changed = false;
|
||||
const nextGroups = { ...params.groups };
|
||||
for (const [groupId, groupValue] of Object.entries(params.groups)) {
|
||||
const group = asObjectRecord(groupValue);
|
||||
if (!group || !Object.hasOwn(group, "allow")) {
|
||||
continue;
|
||||
}
|
||||
const nextGroup = { ...group };
|
||||
if (nextGroup.enabled === undefined) {
|
||||
nextGroup.enabled = group.allow;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.${groupId}.allow → ${params.pathPrefix}.${groupId}.enabled.`,
|
||||
);
|
||||
} else {
|
||||
params.changes.push(
|
||||
`Removed ${params.pathPrefix}.${groupId}.allow (${params.pathPrefix}.${groupId}.enabled already set).`,
|
||||
);
|
||||
}
|
||||
delete nextGroup.allow;
|
||||
nextGroups[groupId] = nextGroup;
|
||||
changed = true;
|
||||
}
|
||||
return { groups: nextGroups, changed };
|
||||
}
|
||||
|
||||
function normalizeGoogleChatEntry(params: {
|
||||
entry: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { entry: Record<string, unknown>; changed: boolean } {
|
||||
let updated = params.entry;
|
||||
let changed = false;
|
||||
|
||||
if (updated.streamMode !== undefined) {
|
||||
updated = { ...updated };
|
||||
delete updated.streamMode;
|
||||
params.changes.push(`Removed ${params.pathPrefix}.streamMode (legacy key no longer used).`);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const groups = asObjectRecord(updated.groups);
|
||||
if (groups) {
|
||||
const normalized = normalizeGoogleChatGroups({
|
||||
groups,
|
||||
pathPrefix: `${params.pathPrefix}.groups`,
|
||||
changes: params.changes,
|
||||
});
|
||||
if (normalized.changed) {
|
||||
updated = { ...updated, groups: normalized.groups };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { entry: updated, changed };
|
||||
}
|
||||
|
||||
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "googlechat"],
|
||||
message: "channels.googlechat.streamMode is legacy and no longer used; it is removed on load.",
|
||||
match: hasLegacyGoogleChatStreamMode,
|
||||
},
|
||||
{
|
||||
path: ["channels", "googlechat", "accounts"],
|
||||
message:
|
||||
"channels.googlechat.accounts.<id>.streamMode is legacy and no longer used; it is removed on load.",
|
||||
match: (value) => hasLegacyAccountAliases(value, hasLegacyGoogleChatStreamMode),
|
||||
},
|
||||
{
|
||||
path: ["channels", "googlechat"],
|
||||
message:
|
||||
'channels.googlechat.groups.<id>.allow is legacy; use channels.googlechat.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
|
||||
match: hasLegacyGoogleChatGroupAllowAlias,
|
||||
},
|
||||
{
|
||||
path: ["channels", "googlechat", "accounts"],
|
||||
message:
|
||||
'channels.googlechat.accounts.<id>.groups.<id>.allow is legacy; use channels.googlechat.accounts.<id>.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
|
||||
match: (value) => hasLegacyAccountAliases(value, hasLegacyGoogleChatGroupAllowAlias),
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeCompatibilityConfig({
|
||||
cfg,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
}): ChannelDoctorConfigMutation {
|
||||
const rawEntry = asObjectRecord(
|
||||
(cfg.channels as Record<string, unknown> | undefined)?.googlechat,
|
||||
);
|
||||
if (!rawEntry) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
let updated = rawEntry;
|
||||
let changed;
|
||||
|
||||
const root = normalizeGoogleChatEntry({
|
||||
entry: updated,
|
||||
pathPrefix: "channels.googlechat",
|
||||
changes,
|
||||
});
|
||||
updated = root.entry;
|
||||
changed = root.changed;
|
||||
|
||||
const accounts = asObjectRecord(updated.accounts);
|
||||
if (accounts) {
|
||||
let accountsChanged = false;
|
||||
const nextAccounts = { ...accounts };
|
||||
for (const [accountId, accountValue] of Object.entries(accounts)) {
|
||||
const account = asObjectRecord(accountValue);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeGoogleChatEntry({
|
||||
entry: account,
|
||||
pathPrefix: `channels.googlechat.accounts.${accountId}`,
|
||||
changes,
|
||||
});
|
||||
if (!normalized.changed) {
|
||||
continue;
|
||||
}
|
||||
nextAccounts[accountId] = normalized.entry;
|
||||
accountsChanged = true;
|
||||
}
|
||||
if (accountsChanged) {
|
||||
updated = { ...updated, accounts: nextAccounts };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
googlechat: updated as GoogleChatChannelsConfig["googlechat"],
|
||||
},
|
||||
},
|
||||
changes,
|
||||
};
|
||||
}
|
||||
58
extensions/googlechat/src/doctor.ts
Normal file
58
extensions/googlechat/src/doctor.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// Googlechat plugin module implements doctor behavior.
|
||||
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function isGoogleChatMutableAllowEntry(raw: string): boolean {
|
||||
const text = raw.trim();
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const withoutPrefix = text.replace(/^(googlechat|google-chat|gchat):/i, "").trim();
|
||||
if (!withoutPrefix) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const withoutUsers = withoutPrefix.replace(/^users\//i, "");
|
||||
return withoutUsers.includes("@");
|
||||
}
|
||||
|
||||
export const collectGoogleChatMutableAllowlistWarnings =
|
||||
createDangerousNameMatchingMutableAllowlistWarningCollector({
|
||||
channel: "googlechat",
|
||||
detector: isGoogleChatMutableAllowEntry,
|
||||
collectLists: (scope) => {
|
||||
const lists = [
|
||||
{
|
||||
pathLabel: `${scope.prefix}.groupAllowFrom`,
|
||||
list: scope.account.groupAllowFrom,
|
||||
},
|
||||
];
|
||||
const dm = asObjectRecord(scope.account.dm);
|
||||
if (dm) {
|
||||
lists.push({
|
||||
pathLabel: `${scope.prefix}.dm.allowFrom`,
|
||||
list: dm.allowFrom,
|
||||
});
|
||||
}
|
||||
const groups = asObjectRecord(scope.account.groups);
|
||||
if (groups) {
|
||||
for (const [groupKey, groupRaw] of Object.entries(groups)) {
|
||||
const group = asObjectRecord(groupRaw);
|
||||
if (!group) {
|
||||
continue;
|
||||
}
|
||||
lists.push({
|
||||
pathLabel: `${scope.prefix}.groups.${groupKey}.users`,
|
||||
list: group.users,
|
||||
});
|
||||
}
|
||||
}
|
||||
return lists;
|
||||
},
|
||||
});
|
||||
97
extensions/googlechat/src/gateway.ts
Normal file
97
extensions/googlechat/src/gateway.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// Googlechat plugin module implements gateway behavior.
|
||||
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
|
||||
import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract";
|
||||
import {
|
||||
createAccountStatusSink,
|
||||
runPassiveAccountLifecycle,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { isGoogleChatNativeApprovalClientEnabled } from "./approval-native.js";
|
||||
import type { GoogleChatRuntimeEnv } from "./monitor-types.js";
|
||||
|
||||
const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(
|
||||
() => import("./channel.runtime.js"),
|
||||
"googleChatChannelRuntime",
|
||||
);
|
||||
|
||||
export async function startGoogleChatGatewayAccount(ctx: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
cfg: OpenClawConfig;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
abortSignal: AbortSignal;
|
||||
channelRuntime?: ChannelRuntimeSurface;
|
||||
setStatus: (next: ChannelAccountSnapshot) => void;
|
||||
log?: {
|
||||
info?: (message: string) => void;
|
||||
};
|
||||
}): Promise<void> {
|
||||
const account = ctx.account;
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: account.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
ctx.log?.info?.(`[${account.accountId}] starting Google Chat webhook`);
|
||||
const { resolveGoogleChatWebhookPath, startGoogleChatMonitor } =
|
||||
await loadGoogleChatChannelRuntime();
|
||||
statusSink({
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
webhookPath: resolveGoogleChatWebhookPath({ account }),
|
||||
audienceType: account.config.audienceType,
|
||||
audience: account.config.audience,
|
||||
});
|
||||
let stopped = false;
|
||||
const markStopped = () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
statusSink({
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
};
|
||||
if (
|
||||
isGoogleChatNativeApprovalClientEnabled({
|
||||
cfg: ctx.cfg,
|
||||
accountId: account.accountId,
|
||||
})
|
||||
) {
|
||||
registerChannelRuntimeContext({
|
||||
channelRuntime: ctx.channelRuntime,
|
||||
channelId: "googlechat",
|
||||
accountId: account.accountId,
|
||||
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
|
||||
context: { account },
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await runPassiveAccountLifecycle({
|
||||
abortSignal: ctx.abortSignal,
|
||||
start: async () =>
|
||||
await startGoogleChatMonitor({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
statusSink,
|
||||
}),
|
||||
stop: async (unregister) => {
|
||||
unregister?.();
|
||||
},
|
||||
onStop: async () => {
|
||||
markStopped();
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
markStopped();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
571
extensions/googlechat/src/google-auth.runtime.test.ts
Normal file
571
extensions/googlechat/src/google-auth.runtime.test.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
// Googlechat tests cover google auth plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buildHostnameAllowlistPolicyFromSuffixAllowlist: vi.fn((hosts: string[]) => ({
|
||||
hostnameAllowlist: hosts,
|
||||
})),
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
gaxiosCtor: vi.fn(
|
||||
function MockGaxios(
|
||||
this: {
|
||||
defaults: Record<string, unknown>;
|
||||
interceptors: {
|
||||
request: { add: ReturnType<typeof vi.fn> };
|
||||
response: { add: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
},
|
||||
defaults,
|
||||
) {
|
||||
this.defaults = defaults as Record<string, unknown>;
|
||||
this.interceptors = {
|
||||
request: { add: vi.fn() },
|
||||
response: { add: vi.fn() },
|
||||
};
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
buildHostnameAllowlistPolicyFromSuffixAllowlist:
|
||||
mocks.buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
||||
fetchWithSsrFGuard: mocks.fetchWithSsrFGuard,
|
||||
}));
|
||||
|
||||
vi.mock("gaxios", () => ({
|
||||
Gaxios: mocks.gaxiosCtor,
|
||||
}));
|
||||
|
||||
let testing: typeof import("./google-auth.runtime.js").testing;
|
||||
let createGoogleAuthFetch: typeof import("./google-auth.runtime.js").createGoogleAuthFetch;
|
||||
let getGoogleAuthTransport: typeof import("./google-auth.runtime.js").getGoogleAuthTransport;
|
||||
let resolveValidatedGoogleChatCredentials: typeof import("./google-auth.runtime.js").resolveValidatedGoogleChatCredentials;
|
||||
|
||||
beforeAll(async () => {
|
||||
({
|
||||
testing,
|
||||
createGoogleAuthFetch,
|
||||
getGoogleAuthTransport,
|
||||
resolveValidatedGoogleChatCredentials,
|
||||
} = await import("./google-auth.runtime.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
testing.resetGoogleAuthRuntimeForTests();
|
||||
mocks.buildHostnameAllowlistPolicyFromSuffixAllowlist.mockClear();
|
||||
mocks.fetchWithSsrFGuard.mockReset();
|
||||
mocks.gaxiosCtor.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
|
||||
vi.doUnmock("gaxios");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0): unknown {
|
||||
const call = mock.mock.calls[callIndex];
|
||||
if (!call) {
|
||||
throw new Error(`Expected mock call ${callIndex}`);
|
||||
}
|
||||
return call[argIndex];
|
||||
}
|
||||
|
||||
describe("googlechat google auth runtime", () => {
|
||||
it("routes Google auth fetches through the SSRF guard and preserves explicit proxy mTLS", async () => {
|
||||
const release = vi.fn();
|
||||
const injectedFetch = vi.fn(globalThis.fetch);
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: new Response("ok", { status: 200 }),
|
||||
release,
|
||||
});
|
||||
|
||||
const guardedFetch = createGoogleAuthFetch(injectedFetch);
|
||||
const response = await guardedFetch("https://oauth2.googleapis.com/token", {
|
||||
agent: { proxy: new URL("http://proxy.example:8080") },
|
||||
cert: "CLIENT_CERT",
|
||||
headers: { "content-type": "application/json" },
|
||||
key: "CLIENT_KEY",
|
||||
method: "POST",
|
||||
proxy: "http://proxy.example:8080",
|
||||
} as RequestInit);
|
||||
|
||||
expect(mocks.fetchWithSsrFGuard).toHaveBeenCalledWith({
|
||||
auditContext: "googlechat.auth.google-auth",
|
||||
dispatcherPolicy: {
|
||||
allowPrivateProxy: true,
|
||||
mode: "explicit-proxy",
|
||||
proxyTls: {
|
||||
cert: "CLIENT_CERT",
|
||||
key: "CLIENT_KEY",
|
||||
},
|
||||
proxyUrl: "http://proxy.example:8080",
|
||||
},
|
||||
fetchImpl: injectedFetch,
|
||||
init: {
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
},
|
||||
policy: {
|
||||
hostnameAllowlist: ["accounts.google.com", "googleapis.com"],
|
||||
},
|
||||
url: "https://oauth2.googleapis.com/token",
|
||||
});
|
||||
await expect(response.text()).resolves.toBe("ok");
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("lets the guard resolve the ambient runtime fetch when no override is injected", async () => {
|
||||
const release = vi.fn();
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: new Response("ok", { status: 200 }),
|
||||
release,
|
||||
});
|
||||
|
||||
const guardedFetch = createGoogleAuthFetch();
|
||||
await guardedFetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(mockCallArg(mocks.fetchWithSsrFGuard)).not.toHaveProperty("fetchImpl");
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps using the guard-selected runtime fetch even if global fetch changes later", async () => {
|
||||
const release = vi.fn();
|
||||
const originalFetch = globalThis.fetch;
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: new Response("ok", { status: 200 }),
|
||||
release,
|
||||
});
|
||||
|
||||
const guardedFetch = createGoogleAuthFetch();
|
||||
(globalThis as Record<string, unknown>).fetch = vi.fn(async () => new Response("patched"));
|
||||
|
||||
try {
|
||||
await guardedFetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
} finally {
|
||||
(globalThis as Record<string, unknown>).fetch = originalFetch;
|
||||
}
|
||||
|
||||
expect(mockCallArg(mocks.fetchWithSsrFGuard)).not.toHaveProperty("fetchImpl");
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("bypasses explicit proxy when noProxy excludes the Google auth host", async () => {
|
||||
const release = vi.fn();
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: new Response("ok", { status: 200 }),
|
||||
release,
|
||||
});
|
||||
|
||||
const guardedFetch = createGoogleAuthFetch();
|
||||
const response = await guardedFetch("https://oauth2.googleapis.com/token", {
|
||||
cert: "CLIENT_CERT",
|
||||
key: "CLIENT_KEY",
|
||||
method: "POST",
|
||||
noProxy: ["oauth2.googleapis.com"],
|
||||
proxy: "http://proxy.example:8080",
|
||||
} as RequestInit);
|
||||
|
||||
expect(mocks.fetchWithSsrFGuard).toHaveBeenCalledWith({
|
||||
auditContext: "googlechat.auth.google-auth",
|
||||
dispatcherPolicy: {
|
||||
connect: {
|
||||
cert: "CLIENT_CERT",
|
||||
key: "CLIENT_KEY",
|
||||
},
|
||||
mode: "direct",
|
||||
},
|
||||
init: {
|
||||
method: "POST",
|
||||
},
|
||||
policy: {
|
||||
hostnameAllowlist: ["accounts.google.com", "googleapis.com"],
|
||||
},
|
||||
url: "https://oauth2.googleapis.com/token",
|
||||
});
|
||||
await expect(response.text()).resolves.toBe("ok");
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves env-proxy transport when HTTPS proxy is configured", async () => {
|
||||
const release = vi.fn();
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: new Response("ok", { status: 200 }),
|
||||
release,
|
||||
});
|
||||
vi.stubEnv("HTTPS_PROXY", "http://env-proxy.example:8080");
|
||||
vi.stubEnv("https_proxy", "http://lower-proxy.example:8080");
|
||||
|
||||
const guardedFetch = createGoogleAuthFetch();
|
||||
const response = await guardedFetch("https://oauth2.googleapis.com/token", {
|
||||
cert: "CLIENT_CERT",
|
||||
key: "CLIENT_KEY",
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(mocks.fetchWithSsrFGuard).toHaveBeenCalledWith({
|
||||
auditContext: "googlechat.auth.google-auth",
|
||||
dispatcherPolicy: {
|
||||
mode: "env-proxy",
|
||||
proxyTls: {
|
||||
cert: "CLIENT_CERT",
|
||||
key: "CLIENT_KEY",
|
||||
},
|
||||
},
|
||||
init: {
|
||||
method: "POST",
|
||||
},
|
||||
policy: {
|
||||
hostnameAllowlist: ["accounts.google.com", "googleapis.com"],
|
||||
},
|
||||
url: "https://oauth2.googleapis.com/token",
|
||||
});
|
||||
await expect(response.text()).resolves.toBe("ok");
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("matches gaxios proxy env precedence for Google auth requests", () => {
|
||||
vi.stubEnv("HTTP_PROXY", "http://upper-http-proxy.example:8080");
|
||||
vi.stubEnv("http_proxy", "http://lower-http-proxy.example:8080");
|
||||
vi.stubEnv("HTTPS_PROXY", "http://upper-https-proxy.example:8080");
|
||||
vi.stubEnv("https_proxy", "http://lower-https-proxy.example:8080");
|
||||
|
||||
expect(testing.resolveGoogleAuthEnvProxyUrl("https")).toBe(
|
||||
"http://upper-https-proxy.example:8080",
|
||||
);
|
||||
expect(testing.resolveGoogleAuthEnvProxyUrl("http")).toBe(
|
||||
"http://upper-http-proxy.example:8080",
|
||||
);
|
||||
});
|
||||
|
||||
it("releases guarded auth fetch resources even when callers do not consume the body", async () => {
|
||||
const release = vi.fn();
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: new Response("ok", { status: 200 }),
|
||||
release,
|
||||
});
|
||||
|
||||
const guardedFetch = createGoogleAuthFetch();
|
||||
const response = await guardedFetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
await expect(response.text()).resolves.toBe("ok");
|
||||
});
|
||||
|
||||
it("rejects oversized guarded auth responses before buffering them into memory", async () => {
|
||||
const release = vi.fn();
|
||||
let chunkIndex = 0;
|
||||
const chunks = [new Uint8Array(700 * 1024), new Uint8Array(400 * 1024)];
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (chunkIndex < chunks.length) {
|
||||
controller.enqueue(chunks[chunkIndex++]);
|
||||
return;
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: new Response(body, { status: 200 }),
|
||||
release,
|
||||
});
|
||||
|
||||
const guardedFetch = createGoogleAuthFetch();
|
||||
|
||||
await expect(
|
||||
guardedFetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
} as RequestInit),
|
||||
).rejects.toThrow("Google auth response exceeds 1048576 bytes.");
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects non-stream guarded auth responses instead of buffering them unbounded", async () => {
|
||||
const release = vi.fn();
|
||||
const arrayBuffer = vi.fn(async () => new ArrayBuffer(16));
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: {
|
||||
arrayBuffer,
|
||||
body: null,
|
||||
headers: new Headers(),
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
} as unknown as Response,
|
||||
release,
|
||||
});
|
||||
|
||||
const guardedFetch = createGoogleAuthFetch();
|
||||
|
||||
await expect(
|
||||
guardedFetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
} as RequestInit),
|
||||
).rejects.toThrow(
|
||||
"Google auth response body stream unavailable; refusing to buffer unbounded response.",
|
||||
);
|
||||
expect(arrayBuffer).not.toHaveBeenCalled();
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects oversized auth responses from content-length before reading the body", async () => {
|
||||
const release = vi.fn();
|
||||
const arrayBuffer = vi.fn(async () => new ArrayBuffer(16));
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: {
|
||||
arrayBuffer,
|
||||
body: null,
|
||||
headers: new Headers({
|
||||
"content-length": String(2 * 1024 * 1024),
|
||||
}),
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
} as unknown as Response,
|
||||
release,
|
||||
});
|
||||
|
||||
const guardedFetch = createGoogleAuthFetch();
|
||||
|
||||
await expect(
|
||||
guardedFetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
} as RequestInit),
|
||||
).rejects.toThrow("Google auth response exceeds 1048576 bytes.");
|
||||
expect(arrayBuffer).not.toHaveBeenCalled();
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects malformed auth content-length before reading the body", async () => {
|
||||
const release = vi.fn();
|
||||
const arrayBuffer = vi.fn(async () => new ArrayBuffer(16));
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: {
|
||||
arrayBuffer,
|
||||
body: null,
|
||||
headers: new Headers({
|
||||
"content-length": "0x3",
|
||||
}),
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
} as unknown as Response,
|
||||
release,
|
||||
});
|
||||
|
||||
const guardedFetch = createGoogleAuthFetch();
|
||||
|
||||
await expect(
|
||||
guardedFetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
} as RequestInit),
|
||||
).rejects.toThrow("invalid content-length header: 0x3");
|
||||
expect(arrayBuffer).not.toHaveBeenCalled();
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("builds a scoped Gaxios transport without mutating global window", async () => {
|
||||
const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
Reflect.deleteProperty(globalThis as object, "window");
|
||||
try {
|
||||
const transport = await getGoogleAuthTransport();
|
||||
const transportDefaults = transport.defaults as { fetchImplementation?: unknown };
|
||||
const requestInterceptorAdd = transport.interceptors.request["add"] as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
const responseInterceptorAdd = transport.interceptors.response[
|
||||
"add"
|
||||
] as unknown as ReturnType<typeof vi.fn>;
|
||||
const requestInterceptor = mockCallArg(requestInterceptorAdd) as
|
||||
| { resolved?: unknown }
|
||||
| undefined;
|
||||
const responseInterceptor = mockCallArg(responseInterceptorAdd) as
|
||||
| { resolved?: unknown }
|
||||
| undefined;
|
||||
|
||||
expect(mocks.gaxiosCtor).toHaveBeenCalledOnce();
|
||||
expect(typeof transportDefaults.fetchImplementation).toBe("function");
|
||||
expect(requestInterceptorAdd).toHaveBeenCalledOnce();
|
||||
expect(typeof requestInterceptor?.resolved).toBe("function");
|
||||
expect(responseInterceptorAdd).toHaveBeenCalledOnce();
|
||||
expect(typeof responseInterceptor?.resolved).toBe("function");
|
||||
expect("window" in globalThis).toBe(false);
|
||||
} finally {
|
||||
if (originalWindowDescriptor) {
|
||||
Object.defineProperty(globalThis, "window", originalWindowDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps auth transports isolated from google-auth interceptor mutations", async () => {
|
||||
const first = await getGoogleAuthTransport();
|
||||
const second = await getGoogleAuthTransport();
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(mocks.gaxiosCtor).toHaveBeenCalledTimes(2);
|
||||
expect(first.interceptors.request["add"]).toHaveBeenCalledOnce();
|
||||
expect(first.interceptors.response["add"]).toHaveBeenCalledOnce();
|
||||
expect(second.interceptors.request["add"]).toHaveBeenCalledOnce();
|
||||
expect(second.interceptors.response["add"]).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("normalizes Google auth request headers before upstream interceptors run", () => {
|
||||
const config = {
|
||||
headers: { "x-test": "1" },
|
||||
url: new URL("https://www.googleapis.com/oauth2/v1/certs"),
|
||||
};
|
||||
|
||||
const normalized = testing.normalizeGoogleAuthPreparedRequestHeaders(config);
|
||||
|
||||
expect(normalized.headers).toBeInstanceOf(Headers);
|
||||
expect(normalized.headers.has("x-test")).toBe(true);
|
||||
expect(normalized.headers.get("x-test")).toBe("1");
|
||||
});
|
||||
|
||||
it("normalizes Google auth response headers before upstream cache-control reads", () => {
|
||||
const response = {
|
||||
data: {},
|
||||
headers: {
|
||||
"cache-control": "public, max-age=3600",
|
||||
},
|
||||
};
|
||||
|
||||
const normalized = testing.normalizeGoogleAuthResponseHeaders(response);
|
||||
|
||||
expect(normalized.headers).toBeInstanceOf(Headers);
|
||||
expect(normalized.headers.get("cache-control")).toBe("public, max-age=3600");
|
||||
});
|
||||
|
||||
it("rejects service-account credentials that override Google auth endpoints", async () => {
|
||||
await expect(
|
||||
resolveValidatedGoogleChatCredentials({
|
||||
accountId: "default",
|
||||
config: {},
|
||||
credentialSource: "inline",
|
||||
credentials: {
|
||||
client_email: "bot@example.iam.gserviceaccount.com",
|
||||
private_key: "key",
|
||||
token_uri: "https://evil.example/token",
|
||||
type: "service_account",
|
||||
},
|
||||
enabled: true,
|
||||
}),
|
||||
).rejects.toThrow(/token_uri/);
|
||||
});
|
||||
|
||||
it("reads and validates service-account files before passing them to google-auth", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "googlechat-auth-"));
|
||||
try {
|
||||
const credentialsPath = path.join(tempDir, "service-account.json");
|
||||
await fs.writeFile(
|
||||
credentialsPath,
|
||||
JSON.stringify({
|
||||
auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs",
|
||||
auth_uri: "https://accounts.google.com/o/oauth2/auth",
|
||||
client_email: "bot@example.iam.gserviceaccount.com",
|
||||
private_key: "key",
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
type: "service_account",
|
||||
universe_domain: "googleapis.com",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const credentials = await resolveValidatedGoogleChatCredentials({
|
||||
accountId: "default",
|
||||
config: {},
|
||||
credentialSource: "file",
|
||||
credentialsFile: credentialsPath,
|
||||
enabled: true,
|
||||
});
|
||||
if (!credentials) {
|
||||
throw new Error("expected validated credentials");
|
||||
}
|
||||
expect(credentials.client_email).toBe("bot@example.iam.gserviceaccount.com");
|
||||
expect(credentials.token_uri).toBe("https://oauth2.googleapis.com/token");
|
||||
expect(credentials.type).toBe("service_account");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts symlinked service-account files used by secret mounts", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "googlechat-auth-link-"));
|
||||
try {
|
||||
const credentialsPath = path.join(tempDir, "service-account.json");
|
||||
const symlinkPath = path.join(tempDir, "service-account-link.json");
|
||||
await fs.writeFile(
|
||||
credentialsPath,
|
||||
JSON.stringify({
|
||||
auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs",
|
||||
auth_uri: "https://accounts.google.com/o/oauth2/auth",
|
||||
client_email: "bot@example.iam.gserviceaccount.com",
|
||||
private_key: "key",
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
type: "service_account",
|
||||
universe_domain: "googleapis.com",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
try {
|
||||
await fs.symlink(credentialsPath, symlinkPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "EPERM") {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const credentials = await resolveValidatedGoogleChatCredentials({
|
||||
accountId: "default",
|
||||
config: {},
|
||||
credentialSource: "file",
|
||||
credentialsFile: symlinkPath,
|
||||
enabled: true,
|
||||
});
|
||||
if (!credentials) {
|
||||
throw new Error("expected validated credentials");
|
||||
}
|
||||
expect(credentials.client_email).toBe("bot@example.iam.gserviceaccount.com");
|
||||
expect(credentials.token_uri).toBe("https://oauth2.googleapis.com/token");
|
||||
expect(credentials.type).toBe("service_account");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not disclose raw credential paths or OS errors when file reads fail", async () => {
|
||||
const missingPath = path.join(os.tmpdir(), "googlechat-auth-missing", "service-account.json");
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await resolveValidatedGoogleChatCredentials({
|
||||
accountId: "default",
|
||||
config: {},
|
||||
credentialSource: "file",
|
||||
credentialsFile: missingPath,
|
||||
enabled: true,
|
||||
});
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
expect((thrown as Error).message).toBe("Failed to load Google Chat service account file.");
|
||||
expect((thrown as Error).message).not.toMatch(
|
||||
/ENOENT|service-account\.json|googlechat-auth-missing/,
|
||||
);
|
||||
});
|
||||
});
|
||||
570
extensions/googlechat/src/google-auth.runtime.ts
Normal file
570
extensions/googlechat/src/google-auth.runtime.ts
Normal file
@@ -0,0 +1,570 @@
|
||||
// Googlechat plugin module implements google auth behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import type { ConnectionOptions } from "node:tls";
|
||||
import { parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime";
|
||||
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
|
||||
import {
|
||||
buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
||||
fetchWithSsrFGuard,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
|
||||
type ProxyRule = RegExp | URL | string;
|
||||
type TlsCert = ConnectionOptions["cert"];
|
||||
type TlsKey = ConnectionOptions["key"];
|
||||
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
type GoogleAuthModule = typeof import("google-auth-library");
|
||||
type GaxiosModule = typeof import("gaxios");
|
||||
type GoogleAuthRuntime = {
|
||||
Gaxios: GaxiosModule["Gaxios"];
|
||||
GoogleAuth: GoogleAuthModule["GoogleAuth"];
|
||||
OAuth2Client: GoogleAuthModule["OAuth2Client"];
|
||||
};
|
||||
type GoogleAuthTransport = InstanceType<GaxiosModule["Gaxios"]>;
|
||||
type GoogleAuthRequestWithUnknownHeaders = RequestInit & {
|
||||
headers?: unknown;
|
||||
};
|
||||
type GoogleAuthResponseWithUnknownHeaders = {
|
||||
headers?: unknown;
|
||||
};
|
||||
type GuardedGoogleAuthRequestInit = RequestInit & {
|
||||
agent?: unknown;
|
||||
cert?: unknown;
|
||||
dispatcher?: unknown;
|
||||
fetchImplementation?: unknown;
|
||||
key?: unknown;
|
||||
noProxy?: unknown;
|
||||
proxy?: unknown;
|
||||
};
|
||||
type TlsOptions = {
|
||||
cert?: TlsCert;
|
||||
key?: TlsKey;
|
||||
};
|
||||
type ProxyAgentLike = {
|
||||
connectOpts?: TlsOptions;
|
||||
proxy: URL;
|
||||
};
|
||||
type TlsAgentLike = {
|
||||
options?: TlsOptions;
|
||||
};
|
||||
type GoogleChatServiceAccountCredentials = Record<string, unknown> & {
|
||||
auth_provider_x509_cert_url?: string;
|
||||
auth_uri?: string;
|
||||
client_email: string;
|
||||
client_x509_cert_url?: string;
|
||||
private_key: string;
|
||||
token_uri?: string;
|
||||
type?: string;
|
||||
universe_domain?: string;
|
||||
};
|
||||
|
||||
const GOOGLE_AUTH_ALLOWED_HOST_SUFFIXES = ["accounts.google.com", "googleapis.com"];
|
||||
const GOOGLE_AUTH_POLICY = buildHostnameAllowlistPolicyFromSuffixAllowlist(
|
||||
GOOGLE_AUTH_ALLOWED_HOST_SUFFIXES,
|
||||
);
|
||||
const GOOGLE_AUTH_AUDIT_CONTEXT = "googlechat.auth.google-auth";
|
||||
const GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/auth";
|
||||
const GOOGLE_AUTH_PROVIDER_CERTS_URL = "https://www.googleapis.com/oauth2/v1/certs";
|
||||
const GOOGLE_AUTH_TOKEN_URI = "https://oauth2.googleapis.com/token";
|
||||
const GOOGLE_AUTH_UNIVERSE_DOMAIN = "googleapis.com";
|
||||
const GOOGLE_CLIENT_CERTS_URL_PREFIX = "https://www.googleapis.com/robot/v1/metadata/x509/";
|
||||
const MAX_GOOGLE_AUTH_RESPONSE_BYTES = 1024 * 1024;
|
||||
const MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES = 64 * 1024;
|
||||
|
||||
let googleAuthRuntimePromise: Promise<GoogleAuthRuntime> | null = null;
|
||||
|
||||
function normalizeGoogleAuthPreparedRequestHeaders<T extends GoogleAuthRequestWithUnknownHeaders>(
|
||||
config: T,
|
||||
): T & { headers: Headers } {
|
||||
if (!(config.headers instanceof Headers)) {
|
||||
config.headers = new Headers(config.headers as HeadersInit | undefined);
|
||||
}
|
||||
return config as T & { headers: Headers };
|
||||
}
|
||||
|
||||
function normalizeGoogleAuthResponseHeaders<T extends GoogleAuthResponseWithUnknownHeaders>(
|
||||
response: T,
|
||||
): T & { headers: Headers } {
|
||||
if (!(response.headers instanceof Headers)) {
|
||||
response.headers = new Headers(response.headers as HeadersInit | undefined);
|
||||
}
|
||||
return response as T & { headers: Headers };
|
||||
}
|
||||
|
||||
function installGoogleAuthHeaderCompatibilityInterceptor(
|
||||
transport: GoogleAuthTransport,
|
||||
): GoogleAuthTransport {
|
||||
transport.interceptors.request.add({
|
||||
resolved: async (config) => normalizeGoogleAuthPreparedRequestHeaders(config),
|
||||
});
|
||||
transport.interceptors.response.add({
|
||||
resolved: async (response) => normalizeGoogleAuthResponseHeaders(response),
|
||||
});
|
||||
return transport;
|
||||
}
|
||||
|
||||
function asNullableObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function hasProxyAgentShape(value: unknown): value is ProxyAgentLike {
|
||||
const record = asNullableObjectRecord(value);
|
||||
return record !== null && record.proxy instanceof URL;
|
||||
}
|
||||
|
||||
function hasTlsAgentShape(value: unknown): value is TlsAgentLike {
|
||||
const record = asNullableObjectRecord(value);
|
||||
return record !== null && asNullableObjectRecord(record.options) !== null;
|
||||
}
|
||||
|
||||
function resolveGoogleAuthAgent(init: GuardedGoogleAuthRequestInit, url: URL): unknown {
|
||||
return typeof init.agent === "function" ? init.agent(url) : init.agent;
|
||||
}
|
||||
|
||||
function hasTlsOptions(options: TlsOptions): boolean {
|
||||
return options.cert !== undefined || options.key !== undefined;
|
||||
}
|
||||
|
||||
function resolveGoogleAuthTlsOptions(init: GuardedGoogleAuthRequestInit, url: URL): TlsOptions {
|
||||
const explicit = {
|
||||
cert: init.cert as TlsCert | undefined,
|
||||
key: init.key as TlsKey | undefined,
|
||||
};
|
||||
if (hasTlsOptions(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const agent = resolveGoogleAuthAgent(init, url);
|
||||
if (hasProxyAgentShape(agent)) {
|
||||
return {
|
||||
cert: agent.connectOpts?.cert,
|
||||
key: agent.connectOpts?.key,
|
||||
};
|
||||
}
|
||||
if (hasTlsAgentShape(agent)) {
|
||||
return {
|
||||
cert: agent.options?.cert,
|
||||
key: agent.options?.key,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function normalizeGoogleAuthProxyEnvValue(value: string | undefined): string | null | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function resolveGoogleAuthEnvProxyUrl(protocol: "http" | "https"): string | undefined {
|
||||
const httpProxy =
|
||||
normalizeGoogleAuthProxyEnvValue(process.env.HTTP_PROXY) ??
|
||||
normalizeGoogleAuthProxyEnvValue(process.env.http_proxy);
|
||||
const httpsProxy =
|
||||
normalizeGoogleAuthProxyEnvValue(process.env.HTTPS_PROXY) ??
|
||||
normalizeGoogleAuthProxyEnvValue(process.env.https_proxy);
|
||||
if (protocol === "https") {
|
||||
return httpsProxy ?? httpProxy ?? undefined;
|
||||
}
|
||||
return httpProxy ?? undefined;
|
||||
}
|
||||
|
||||
function collectGoogleAuthNoProxyRules(noProxy: ProxyRule[] = []): ProxyRule[] {
|
||||
const rules = [...noProxy];
|
||||
const envRules = (process.env.NO_PROXY ?? process.env.no_proxy)?.split(",") ?? [];
|
||||
for (const rule of envRules) {
|
||||
const trimmed = rule.trim();
|
||||
if (trimmed.length > 0) {
|
||||
rules.push(trimmed);
|
||||
}
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
function shouldBypassGoogleAuthProxy(url: URL, noProxy: ProxyRule[] = []): boolean {
|
||||
for (const rule of collectGoogleAuthNoProxyRules(noProxy)) {
|
||||
if (rule instanceof RegExp) {
|
||||
if (rule.test(url.toString())) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (rule instanceof URL) {
|
||||
if (rule.origin === url.origin) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (rule.startsWith("*.") || rule.startsWith(".")) {
|
||||
const cleanedRule = rule.replace(/^\*\./, ".");
|
||||
if (url.hostname.endsWith(cleanedRule)) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (rule === url.origin || rule === url.hostname || rule === url.href) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readGoogleAuthProxyUrl(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (value instanceof URL) {
|
||||
return value.toString();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readOptionalTrimmedString(
|
||||
record: Record<string, unknown>,
|
||||
fieldName: string,
|
||||
): string | undefined {
|
||||
const value = record[fieldName];
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`Google Chat service account field "${fieldName}" must be a string`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`Google Chat service account field "${fieldName}" cannot be empty`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function readRequiredTrimmedString(record: Record<string, unknown>, fieldName: string): string {
|
||||
return (
|
||||
readOptionalTrimmedString(record, fieldName) ??
|
||||
(() => {
|
||||
throw new Error(`Google Chat service account is missing "${fieldName}"`);
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
function assertExactUrlField(
|
||||
record: Record<string, unknown>,
|
||||
fieldName: string,
|
||||
expectedUrl: string,
|
||||
): void {
|
||||
const value = readOptionalTrimmedString(record, fieldName);
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (value !== expectedUrl) {
|
||||
throw new Error(
|
||||
`Google Chat service account field "${fieldName}" must be ${expectedUrl}, got ${value}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertUrlPrefixField(
|
||||
record: Record<string, unknown>,
|
||||
fieldName: string,
|
||||
expectedPrefix: string,
|
||||
): void {
|
||||
const value = readOptionalTrimmedString(record, fieldName);
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (!value.startsWith(expectedPrefix)) {
|
||||
throw new Error(
|
||||
`Google Chat service account field "${fieldName}" must start with ${expectedPrefix}, got ${value}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateGoogleChatServiceAccountCredentials(
|
||||
credentials: Record<string, unknown>,
|
||||
): GoogleChatServiceAccountCredentials {
|
||||
const type = readOptionalTrimmedString(credentials, "type");
|
||||
if (type && type !== "service_account") {
|
||||
throw new Error(`Google Chat credentials must use service_account auth, got "${type}" instead`);
|
||||
}
|
||||
|
||||
readRequiredTrimmedString(credentials, "client_email");
|
||||
readRequiredTrimmedString(credentials, "private_key");
|
||||
|
||||
const universeDomain = readOptionalTrimmedString(credentials, "universe_domain");
|
||||
if (universeDomain && universeDomain !== GOOGLE_AUTH_UNIVERSE_DOMAIN) {
|
||||
throw new Error(
|
||||
`Google Chat service account field "universe_domain" must be ${GOOGLE_AUTH_UNIVERSE_DOMAIN}, got ${universeDomain}`,
|
||||
);
|
||||
}
|
||||
|
||||
assertExactUrlField(credentials, "auth_uri", GOOGLE_AUTH_URI);
|
||||
assertExactUrlField(credentials, "auth_provider_x509_cert_url", GOOGLE_AUTH_PROVIDER_CERTS_URL);
|
||||
assertExactUrlField(credentials, "token_uri", GOOGLE_AUTH_TOKEN_URI);
|
||||
assertUrlPrefixField(credentials, "client_x509_cert_url", GOOGLE_CLIENT_CERTS_URL_PREFIX);
|
||||
|
||||
return credentials as GoogleChatServiceAccountCredentials;
|
||||
}
|
||||
|
||||
async function readCredentialsFile(filePath: string): Promise<Record<string, unknown>> {
|
||||
const resolvedPath = resolveUserPath(filePath);
|
||||
if (!resolvedPath) {
|
||||
throw new Error("Google Chat service account file path is empty");
|
||||
}
|
||||
|
||||
let handle: Awaited<ReturnType<typeof fs.open>> | null;
|
||||
try {
|
||||
handle = await fs.open(resolvedPath, "r");
|
||||
} catch {
|
||||
throw new Error("Failed to load Google Chat service account file.");
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("Google Chat service account file must be a regular file.");
|
||||
}
|
||||
if (stat.size > MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES) {
|
||||
throw new Error(
|
||||
`Google Chat service account file exceeds ${MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES} bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await handle.readFile({ encoding: "utf8" });
|
||||
} catch {
|
||||
throw new Error("Failed to load Google Chat service account file.");
|
||||
}
|
||||
if (Buffer.byteLength(raw, "utf8") > MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES) {
|
||||
throw new Error(
|
||||
`Google Chat service account file exceeds ${MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES} bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("Invalid Google Chat service account JSON.");
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Google Chat service account file must contain a JSON object.");
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} finally {
|
||||
await handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeGoogleAuthInit(init?: RequestInit): RequestInit | undefined {
|
||||
if (!init) {
|
||||
return undefined;
|
||||
}
|
||||
const nextInit = { ...(init as GuardedGoogleAuthRequestInit) };
|
||||
delete nextInit.agent;
|
||||
delete nextInit.cert;
|
||||
delete nextInit.dispatcher;
|
||||
delete nextInit.fetchImplementation;
|
||||
delete nextInit.key;
|
||||
delete nextInit.noProxy;
|
||||
delete nextInit.proxy;
|
||||
return nextInit;
|
||||
}
|
||||
|
||||
function resolveGoogleAuthDispatcherPolicy(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): {
|
||||
dispatcherPolicy?: PinnedDispatcherPolicy;
|
||||
init?: RequestInit;
|
||||
} {
|
||||
const requestUrl =
|
||||
input instanceof Request
|
||||
? new URL(input.url)
|
||||
: new URL(typeof input === "string" ? input : input.toString());
|
||||
const nextInit = sanitizeGoogleAuthInit(init);
|
||||
const googleAuthInit = (init ?? {}) as GuardedGoogleAuthRequestInit;
|
||||
const tlsOptions = resolveGoogleAuthTlsOptions(googleAuthInit, requestUrl);
|
||||
const proxyBypassed = shouldBypassGoogleAuthProxy(
|
||||
requestUrl,
|
||||
Array.isArray(googleAuthInit.noProxy) ? (googleAuthInit.noProxy as ProxyRule[]) : [],
|
||||
);
|
||||
const agent = resolveGoogleAuthAgent(googleAuthInit, requestUrl);
|
||||
const explicitProxy =
|
||||
readGoogleAuthProxyUrl(googleAuthInit.proxy) ??
|
||||
(hasProxyAgentShape(agent) ? agent.proxy.toString() : undefined);
|
||||
|
||||
if (!proxyBypassed && explicitProxy) {
|
||||
return {
|
||||
dispatcherPolicy: {
|
||||
allowPrivateProxy: true,
|
||||
mode: "explicit-proxy",
|
||||
...(hasTlsOptions(tlsOptions) ? { proxyTls: { ...tlsOptions } } : {}),
|
||||
proxyUrl: explicitProxy,
|
||||
},
|
||||
init: nextInit,
|
||||
};
|
||||
}
|
||||
|
||||
const envProxyUrl = proxyBypassed
|
||||
? undefined
|
||||
: resolveGoogleAuthEnvProxyUrl(requestUrl.protocol === "http:" ? "http" : "https");
|
||||
if (envProxyUrl) {
|
||||
return {
|
||||
dispatcherPolicy: {
|
||||
mode: "env-proxy",
|
||||
...(hasTlsOptions(tlsOptions) ? { proxyTls: { ...tlsOptions } } : {}),
|
||||
},
|
||||
init: nextInit,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasTlsOptions(tlsOptions)) {
|
||||
return {
|
||||
dispatcherPolicy: {
|
||||
connect: { ...tlsOptions },
|
||||
mode: "direct",
|
||||
},
|
||||
init: nextInit,
|
||||
};
|
||||
}
|
||||
|
||||
return { init: nextInit };
|
||||
}
|
||||
|
||||
export function createGoogleAuthFetch(baseFetch?: FetchLike): FetchLike {
|
||||
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
const guardedOptions = resolveGoogleAuthDispatcherPolicy(input, init);
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
auditContext: GOOGLE_AUTH_AUDIT_CONTEXT,
|
||||
dispatcherPolicy: guardedOptions.dispatcherPolicy,
|
||||
init: guardedOptions.init,
|
||||
policy: GOOGLE_AUTH_POLICY,
|
||||
url,
|
||||
...(baseFetch ? { fetchImpl: baseFetch } : {}),
|
||||
});
|
||||
try {
|
||||
const body = await readGoogleAuthResponseBytes(response);
|
||||
const bufferedBody = Uint8Array.from(body);
|
||||
return new Response(bufferedBody.buffer, {
|
||||
headers: response.headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function readGoogleAuthResponseBytes(response: Response): Promise<Uint8Array> {
|
||||
const contentLengthHeader = response.headers.get("content-length");
|
||||
if (contentLengthHeader) {
|
||||
const contentLength = parseMediaContentLength(contentLengthHeader);
|
||||
if (contentLength !== null && contentLength > MAX_GOOGLE_AUTH_RESPONSE_BYTES) {
|
||||
throw new Error(`Google auth response exceeds ${MAX_GOOGLE_AUTH_RESPONSE_BYTES} bytes.`);
|
||||
}
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error(
|
||||
"Google auth response body stream unavailable; refusing to buffer unbounded response.",
|
||||
);
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
total += value.byteLength;
|
||||
if (total > MAX_GOOGLE_AUTH_RESPONSE_BYTES) {
|
||||
try {
|
||||
await reader.cancel("Google auth response exceeded buffer limit");
|
||||
} catch {
|
||||
// Ignore cancellation errors; the caller still releases the dispatcher.
|
||||
}
|
||||
throw new Error(`Google auth response exceeds ${MAX_GOOGLE_AUTH_RESPONSE_BYTES} bytes.`);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function loadGoogleAuthRuntime(): Promise<GoogleAuthRuntime> {
|
||||
if (!googleAuthRuntimePromise) {
|
||||
googleAuthRuntimePromise = (async () => {
|
||||
try {
|
||||
const [googleAuthModule, gaxiosModule] = await Promise.all([
|
||||
import("google-auth-library"),
|
||||
import("gaxios"),
|
||||
]);
|
||||
return {
|
||||
Gaxios: gaxiosModule.Gaxios,
|
||||
GoogleAuth: googleAuthModule.GoogleAuth,
|
||||
OAuth2Client: googleAuthModule.OAuth2Client,
|
||||
};
|
||||
} catch (error) {
|
||||
googleAuthRuntimePromise = null;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return await googleAuthRuntimePromise;
|
||||
}
|
||||
|
||||
export async function getGoogleAuthTransport(): Promise<GoogleAuthTransport> {
|
||||
const { Gaxios } = await loadGoogleAuthRuntime();
|
||||
return installGoogleAuthHeaderCompatibilityInterceptor(
|
||||
new Gaxios({
|
||||
fetchImplementation: createGoogleAuthFetch(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveValidatedGoogleChatCredentials(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
): Promise<GoogleChatServiceAccountCredentials | null> {
|
||||
if (account.credentials) {
|
||||
return validateGoogleChatServiceAccountCredentials(account.credentials);
|
||||
}
|
||||
if (account.credentialsFile) {
|
||||
const fileCredentials = await readCredentialsFile(account.credentialsFile);
|
||||
return validateGoogleChatServiceAccountCredentials(fileCredentials);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
resetGoogleAuthRuntimeForTests(): void {
|
||||
googleAuthRuntimePromise = null;
|
||||
},
|
||||
normalizeGoogleAuthPreparedRequestHeaders,
|
||||
normalizeGoogleAuthResponseHeaders,
|
||||
resolveGoogleAuthEnvProxyUrl,
|
||||
validateGoogleChatServiceAccountCredentials,
|
||||
};
|
||||
export { testing as __testing };
|
||||
18
extensions/googlechat/src/group-policy.ts
Normal file
18
extensions/googlechat/src/group-policy.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Googlechat plugin module implements group policy behavior.
|
||||
import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
||||
|
||||
type GoogleChatGroupContext = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
groupId?: string | null;
|
||||
};
|
||||
|
||||
export function resolveGoogleChatGroupRequireMention(params: GoogleChatGroupContext): boolean {
|
||||
return resolveChannelGroupRequireMention({
|
||||
cfg: params.cfg,
|
||||
channel: "googlechat",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
492
extensions/googlechat/src/monitor-access.test.ts
Normal file
492
extensions/googlechat/src/monitor-access.test.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
// Googlechat tests cover monitor access plugin behavior.
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const createChannelPairingController = vi.hoisted(() => vi.fn());
|
||||
const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn());
|
||||
const resolveAllowlistProviderRuntimeGroupPolicy = vi.hoisted(() => vi.fn());
|
||||
const resolveDefaultGroupPolicy = vi.hoisted(() => vi.fn());
|
||||
const warnMissingProviderGroupPolicyFallbackOnce = vi.hoisted(() => vi.fn());
|
||||
const sendGoogleChatMessage = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
GROUP_POLICY_BLOCKED_LABEL: { space: "space" },
|
||||
createChannelPairingController,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
}));
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
sendGoogleChatMessage,
|
||||
}));
|
||||
|
||||
function createCore() {
|
||||
return {
|
||||
channel: {
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: vi.fn(() => false),
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
||||
shouldHandleTextCommands: vi.fn(() => false),
|
||||
isControlCommandMessage: vi.fn(() => false),
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: vi.fn(() => false),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function primeCommonDefaults() {
|
||||
isDangerousNameMatchingEnabled.mockReturnValue(false);
|
||||
resolveDefaultGroupPolicy.mockReturnValue("allowlist");
|
||||
resolveAllowlistProviderRuntimeGroupPolicy.mockReturnValue({
|
||||
groupPolicy: "allowlist",
|
||||
providerMissingFallbackApplied: false,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce.mockReturnValue(undefined);
|
||||
}
|
||||
|
||||
const baseAccessConfig = {
|
||||
channels: { googlechat: {} },
|
||||
commands: { useAccessGroups: true },
|
||||
} as const;
|
||||
|
||||
const defaultSender = {
|
||||
senderId: "users/alice",
|
||||
senderName: "Alice",
|
||||
senderEmail: "alice@example.com",
|
||||
} as const;
|
||||
|
||||
let applyGoogleChatInboundAccessPolicy: typeof import("./monitor-access.js").applyGoogleChatInboundAccessPolicy;
|
||||
|
||||
function allowInboundGroupTraffic() {
|
||||
createChannelPairingController.mockReturnValue({
|
||||
readAllowFromStore: vi.fn(async () => []),
|
||||
issueChallenge: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
async function applyInboundAccessPolicy(
|
||||
overrides: Partial<Parameters<typeof applyGoogleChatInboundAccessPolicy>[0]>,
|
||||
) {
|
||||
return applyGoogleChatInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {},
|
||||
} as never,
|
||||
config: baseAccessConfig as never,
|
||||
core: createCore() as never,
|
||||
space: { name: "spaces/AAA", displayName: "Team Room" } as never,
|
||||
message: { annotations: [] } as never,
|
||||
isGroup: true,
|
||||
rawBody: "hello team",
|
||||
logVerbose: vi.fn(),
|
||||
...defaultSender,
|
||||
...overrides,
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe("googlechat inbound access policy", () => {
|
||||
beforeAll(async () => {
|
||||
({ applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("../runtime-api.js");
|
||||
vi.doUnmock("./api.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "blocks raw email entries when dangerous name matching is disabled",
|
||||
allowNameMatching: false,
|
||||
allowFrom: ["jane@example.com"],
|
||||
senderId: "users/123",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "matches raw email entries when dangerous name matching is enabled",
|
||||
allowNameMatching: true,
|
||||
allowFrom: ["jane@example.com"],
|
||||
senderId: "users/123",
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "does not treat users/<email> entries as email allowlist entries",
|
||||
allowNameMatching: true,
|
||||
allowFrom: ["users/jane@example.com"],
|
||||
senderId: "users/123",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "matches user id entries",
|
||||
allowNameMatching: false,
|
||||
allowFrom: ["users/abc"],
|
||||
senderId: "users/abc",
|
||||
ok: true,
|
||||
},
|
||||
])("$name", async ({ allowNameMatching, allowFrom, senderId, ok }) => {
|
||||
primeCommonDefaults();
|
||||
isDangerousNameMatchingEnabled.mockReturnValue(allowNameMatching);
|
||||
createChannelPairingController.mockReturnValue({
|
||||
readAllowFromStore: vi.fn(async () => []),
|
||||
issueChallenge: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await applyInboundAccessPolicy({
|
||||
isGroup: false,
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
senderId,
|
||||
senderEmail: "Jane@Example.com",
|
||||
});
|
||||
expect(result.ok).toBe(ok);
|
||||
});
|
||||
|
||||
it("issues a pairing challenge for unauthorized DMs in pairing mode", async () => {
|
||||
primeCommonDefaults();
|
||||
const now = new Date("2026-05-09T06:35:00.000Z").getTime();
|
||||
const issueChallenge = vi.fn(async ({ onCreated, sendPairingReply }) => {
|
||||
onCreated?.();
|
||||
await sendPairingReply("pairing text");
|
||||
});
|
||||
createChannelPairingController.mockReturnValue({
|
||||
readAllowFromStore: vi.fn(async () => []),
|
||||
issueChallenge,
|
||||
});
|
||||
sendGoogleChatMessage.mockResolvedValue({ ok: true });
|
||||
|
||||
const statusSink = vi.fn();
|
||||
const logVerbose = vi.fn();
|
||||
const account = {
|
||||
accountId: "default",
|
||||
config: {
|
||||
dm: { policy: "pairing" },
|
||||
},
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
try {
|
||||
await expect(
|
||||
applyGoogleChatInboundAccessPolicy({
|
||||
account: account as never,
|
||||
config: {
|
||||
channels: { googlechat: {} },
|
||||
} as never,
|
||||
core: createCore() as never,
|
||||
space: { name: "spaces/AAA", displayName: "DM" } as never,
|
||||
message: { annotations: [] } as never,
|
||||
isGroup: false,
|
||||
senderId: "users/abc",
|
||||
senderName: "Alice",
|
||||
senderEmail: "alice@example.com",
|
||||
rawBody: "hello",
|
||||
statusSink,
|
||||
logVerbose,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false });
|
||||
|
||||
expect(issueChallenge).toHaveBeenCalledTimes(1);
|
||||
expect(sendGoogleChatMessage).toHaveBeenCalledWith({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "pairing text",
|
||||
});
|
||||
expect(statusSink).toHaveBeenCalledWith({
|
||||
lastOutboundAt: now,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("allows group traffic when sender and mention gates pass", async () => {
|
||||
primeCommonDefaults();
|
||||
allowInboundGroupTraffic();
|
||||
const core = createCore();
|
||||
core.channel.commands.shouldComputeCommandAuthorized.mockReturnValue(true);
|
||||
core.channel.commands.resolveCommandAuthorizedFromAuthorizers.mockReturnValue(true);
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
botUser: "users/app-bot",
|
||||
groups: {
|
||||
"spaces/AAA": {
|
||||
users: ["users/alice"],
|
||||
requireMention: true,
|
||||
systemPrompt: " group prompt ",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
core: core as never,
|
||||
message: {
|
||||
annotations: [
|
||||
{
|
||||
type: "USER_MENTION",
|
||||
userMention: { user: { name: "users/app-bot" } },
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
commandAuthorized: true,
|
||||
effectiveWasMentioned: true,
|
||||
groupSystemPrompt: "group prompt",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows group traffic from generic message sender access groups", async () => {
|
||||
primeCommonDefaults();
|
||||
allowInboundGroupTraffic();
|
||||
|
||||
const result = await applyInboundAccessPolicy({
|
||||
config: {
|
||||
...baseAccessConfig,
|
||||
accessGroups: {
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
googlechat: ["users/alice"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
groups: {
|
||||
"spaces/AAA": {
|
||||
users: ["accessGroup:operators"],
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("expands generic message sender access groups before DM access checks", async () => {
|
||||
primeCommonDefaults();
|
||||
const readAllowFromStore = vi.fn(async () => []);
|
||||
createChannelPairingController.mockReturnValue({
|
||||
readAllowFromStore,
|
||||
issueChallenge: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await applyInboundAccessPolicy({
|
||||
isGroup: false,
|
||||
config: {
|
||||
...baseAccessConfig,
|
||||
accessGroups: {
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
googlechat: ["users/alice"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
allowFrom: ["accessGroup:operators"],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
expect(readAllowFromStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves allowlist group policy when a routed space has no sender allowlist", async () => {
|
||||
primeCommonDefaults();
|
||||
allowInboundGroupTraffic();
|
||||
const logVerbose = vi.fn();
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
allowFrom: ["users/alice"],
|
||||
},
|
||||
groups: {
|
||||
"spaces/AAA": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
logVerbose,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false });
|
||||
|
||||
expect(logVerbose).toHaveBeenCalledWith(
|
||||
"drop group message (sender policy blocked, reason=groupPolicy=allowlist (empty allowlist), space=spaces/AAA)",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps configured space users sender-scoped when group policy is open", async () => {
|
||||
primeCommonDefaults();
|
||||
resolveAllowlistProviderRuntimeGroupPolicy.mockReturnValue({
|
||||
groupPolicy: "open",
|
||||
providerMissingFallbackApplied: false,
|
||||
});
|
||||
allowInboundGroupTraffic();
|
||||
const logVerbose = vi.fn();
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"spaces/AAA": {
|
||||
users: ["users/bob"],
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
logVerbose,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false });
|
||||
|
||||
expect(logVerbose).toHaveBeenCalledWith("drop group message (sender not allowed, users/alice)");
|
||||
});
|
||||
|
||||
it("drops unauthorized group control commands", async () => {
|
||||
primeCommonDefaults();
|
||||
allowInboundGroupTraffic();
|
||||
resolveAllowlistProviderRuntimeGroupPolicy.mockReturnValue({
|
||||
groupPolicy: "open",
|
||||
providerMissingFallbackApplied: false,
|
||||
});
|
||||
const core = createCore();
|
||||
core.channel.commands.shouldComputeCommandAuthorized.mockReturnValue(true);
|
||||
core.channel.commands.isControlCommandMessage.mockReturnValue(true);
|
||||
const logVerbose = vi.fn();
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
core: core as never,
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
groups: {
|
||||
"spaces/AAA": {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
rawBody: "/admin",
|
||||
logVerbose,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false });
|
||||
|
||||
expect(logVerbose).toHaveBeenCalledWith("googlechat: drop control command from users/alice");
|
||||
});
|
||||
|
||||
it("does not match group policy by mutable space displayName when the stable id differs", async () => {
|
||||
primeCommonDefaults();
|
||||
allowInboundGroupTraffic();
|
||||
const logVerbose = vi.fn();
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
groups: {
|
||||
"Finance Ops": {
|
||||
users: ["users/alice"],
|
||||
requireMention: true,
|
||||
systemPrompt: "finance-only prompt",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
core: createCore() as never,
|
||||
space: { name: "spaces/BBB", displayName: "Finance Ops" } as never,
|
||||
message: {
|
||||
annotations: [
|
||||
{
|
||||
type: "USER_MENTION",
|
||||
userMention: { user: { name: "users/app" } },
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
rawBody: "show quarter close status",
|
||||
logVerbose,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false });
|
||||
|
||||
expect(logVerbose).toHaveBeenCalledWith(
|
||||
"Deprecated Google Chat group key detected: group routing now requires stable space ids (spaces/<spaceId>). Update channels.googlechat.groups keys: Finance Ops",
|
||||
);
|
||||
expect(logVerbose).toHaveBeenCalledWith(
|
||||
"drop group message (deprecated mutable group key matched, space=spaces/BBB)",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed instead of falling back to wildcard when a deprecated room key matches", async () => {
|
||||
primeCommonDefaults();
|
||||
resolveAllowlistProviderRuntimeGroupPolicy.mockReturnValue({
|
||||
groupPolicy: "open",
|
||||
providerMissingFallbackApplied: false,
|
||||
});
|
||||
allowInboundGroupTraffic();
|
||||
const logVerbose = vi.fn();
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"*": {
|
||||
users: ["users/alice"],
|
||||
},
|
||||
"Finance Ops": {
|
||||
enabled: false,
|
||||
users: ["users/bob"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
core: createCore() as never,
|
||||
space: { name: "spaces/BBB", displayName: "Finance Ops" } as never,
|
||||
rawBody: "show quarter close status",
|
||||
logVerbose,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false });
|
||||
|
||||
expect(logVerbose).toHaveBeenCalledWith(
|
||||
"drop group message (deprecated mutable group key matched, space=spaces/BBB)",
|
||||
);
|
||||
});
|
||||
});
|
||||
466
extensions/googlechat/src/monitor-access.ts
Normal file
466
extensions/googlechat/src/monitor-access.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
// Googlechat plugin module implements monitor access behavior.
|
||||
import {
|
||||
channelIngressRoutes,
|
||||
createChannelIngressResolver,
|
||||
defineStableChannelIngressIdentity,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import type { ChannelBotLoopProtectionConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
normalizeStringEntries,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
createChannelPairingController,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
type OpenClawConfig,
|
||||
} from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { sendGoogleChatMessage } from "./api.js";
|
||||
import type { GoogleChatCoreRuntime } from "./monitor-types.js";
|
||||
import type { GoogleChatAnnotation, GoogleChatMessage, GoogleChatSpace } from "./types.js";
|
||||
|
||||
function normalizeUserId(raw?: string | null): string {
|
||||
const trimmed = normalizeOptionalString(raw) ?? "";
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, ""));
|
||||
}
|
||||
|
||||
const GOOGLECHAT_EMAIL_KIND = "plugin:googlechat-email" as const;
|
||||
|
||||
function normalizeEntryValue(raw?: string | null): string {
|
||||
return normalizeLowercaseStringOrEmpty(raw ?? "");
|
||||
}
|
||||
|
||||
function normalizeGoogleChatStableEntry(entry: string): string | null {
|
||||
const withoutProvider = normalizeEntryValue(entry).replace(
|
||||
/^(googlechat|google-chat|gchat):/i,
|
||||
"",
|
||||
);
|
||||
if (!withoutProvider) {
|
||||
return null;
|
||||
}
|
||||
return withoutProvider.startsWith("users/") ? normalizeUserId(withoutProvider) : withoutProvider;
|
||||
}
|
||||
|
||||
function normalizeGoogleChatEmailEntry(entry: string): string | null {
|
||||
const withoutProvider = normalizeEntryValue(entry).replace(
|
||||
/^(googlechat|google-chat|gchat):/i,
|
||||
"",
|
||||
);
|
||||
if (withoutProvider.startsWith("users/")) {
|
||||
return null;
|
||||
}
|
||||
const stable = normalizeGoogleChatStableEntry(entry);
|
||||
return stable?.includes("@") ? stable : null;
|
||||
}
|
||||
|
||||
const googleChatIngressIdentity = defineStableChannelIngressIdentity({
|
||||
key: "sender-id",
|
||||
normalizeEntry: normalizeGoogleChatStableEntry,
|
||||
normalizeSubject: normalizeUserId,
|
||||
aliases: [
|
||||
{
|
||||
key: "email",
|
||||
kind: GOOGLECHAT_EMAIL_KIND,
|
||||
normalizeEntry: normalizeGoogleChatEmailEntry,
|
||||
normalizeSubject: normalizeEntryValue,
|
||||
dangerous: true,
|
||||
},
|
||||
],
|
||||
isWildcardEntry: (entry) => normalizeEntryValue(entry) === "*",
|
||||
resolveEntryId: ({ entryIndex, fieldKey }) =>
|
||||
fieldKey === "stableId"
|
||||
? `entry-${entryIndex + 1}:user`
|
||||
: `entry-${entryIndex + 1}:${fieldKey}`,
|
||||
});
|
||||
|
||||
type GoogleChatGroupEntry = {
|
||||
requireMention?: boolean;
|
||||
enabled?: boolean;
|
||||
botLoopProtection?: ChannelBotLoopProtectionConfig;
|
||||
users?: Array<string | number>;
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
function resolveGroupConfig(params: {
|
||||
groupId: string;
|
||||
groupName?: string | null;
|
||||
groups?: Record<string, GoogleChatGroupEntry>;
|
||||
}) {
|
||||
const { groupId, groupName, groups } = params;
|
||||
const entries = groups ?? {};
|
||||
const keys = Object.keys(entries);
|
||||
if (keys.length === 0) {
|
||||
return { entry: undefined, allowlistConfigured: false, deprecatedNameMatch: false };
|
||||
}
|
||||
const entry = entries[groupId];
|
||||
const normalizedGroupName = normalizeLowercaseStringOrEmpty(groupName ?? "");
|
||||
const deprecatedNameMatch =
|
||||
!entry &&
|
||||
Boolean(
|
||||
groupName &&
|
||||
keys.some((key) => {
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed || trimmed === "*" || /^spaces\//i.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
trimmed === groupName || normalizeLowercaseStringOrEmpty(trimmed) === normalizedGroupName
|
||||
);
|
||||
}),
|
||||
);
|
||||
const fallback = entries["*"];
|
||||
return {
|
||||
entry: deprecatedNameMatch ? undefined : (entry ?? fallback),
|
||||
allowlistConfigured: true,
|
||||
fallback,
|
||||
deprecatedNameMatch,
|
||||
};
|
||||
}
|
||||
|
||||
function extractMentionInfo(annotations: GoogleChatAnnotation[], botUser?: string | null) {
|
||||
const mentionAnnotations = annotations.filter((entry) => entry.type === "USER_MENTION");
|
||||
const hasAnyMention = mentionAnnotations.length > 0;
|
||||
const botTargets = new Set(["users/app", botUser?.trim()].filter(Boolean) as string[]);
|
||||
const wasMentioned = mentionAnnotations.some((entry) => {
|
||||
const userName = entry.userMention?.user?.name;
|
||||
if (!userName) {
|
||||
return false;
|
||||
}
|
||||
if (botTargets.has(userName)) {
|
||||
return true;
|
||||
}
|
||||
return normalizeUserId(userName) === "app";
|
||||
});
|
||||
return { hasAnyMention, wasMentioned };
|
||||
}
|
||||
|
||||
const warnedDeprecatedUsersEmailAllowFrom = new Set<string>();
|
||||
const warnedMutableGroupKeys = new Set<string>();
|
||||
|
||||
function warnDeprecatedUsersEmailEntries(logVerbose: (message: string) => void, entries: string[]) {
|
||||
const deprecated = entries
|
||||
.map((v) => normalizeOptionalString(v))
|
||||
.filter((v): v is string => Boolean(v))
|
||||
.filter((v) => /^users\/.+@.+/i.test(v));
|
||||
if (deprecated.length === 0) {
|
||||
return;
|
||||
}
|
||||
const key = deprecated
|
||||
.map((v) => normalizeLowercaseStringOrEmpty(v))
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
.join(",");
|
||||
if (warnedDeprecatedUsersEmailAllowFrom.has(key)) {
|
||||
return;
|
||||
}
|
||||
warnedDeprecatedUsersEmailAllowFrom.add(key);
|
||||
logVerbose(
|
||||
`Deprecated allowFrom entry detected: "users/<email>" is no longer treated as an email allowlist. Use raw email (alice@example.com) or immutable user id (users/<id>). entries=${deprecated.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function warnMutableGroupKeysConfigured(
|
||||
logVerbose: (message: string) => void,
|
||||
groups?: Record<string, GoogleChatGroupEntry>,
|
||||
) {
|
||||
const mutableKeys = Object.keys(groups ?? {})
|
||||
.map((key) => key.trim())
|
||||
.filter((key) => key && key !== "*" && !/^spaces\//i.test(key));
|
||||
if (mutableKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
const warningKey = mutableKeys
|
||||
.map((key) => normalizeLowercaseStringOrEmpty(key))
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
.join(",");
|
||||
if (warnedMutableGroupKeys.has(warningKey)) {
|
||||
return;
|
||||
}
|
||||
warnedMutableGroupKeys.add(warningKey);
|
||||
logVerbose(
|
||||
`Deprecated Google Chat group key detected: group routing now requires stable space ids (spaces/<spaceId>). Update channels.googlechat.groups keys: ${mutableKeys.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function applyGoogleChatInboundAccessPolicy(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
config: OpenClawConfig;
|
||||
core: GoogleChatCoreRuntime;
|
||||
space: GoogleChatSpace;
|
||||
message: GoogleChatMessage;
|
||||
isGroup: boolean;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
senderEmail?: string;
|
||||
rawBody: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
logVerbose: (message: string) => void;
|
||||
}): Promise<
|
||||
| {
|
||||
ok: true;
|
||||
commandAuthorized: boolean | undefined;
|
||||
effectiveWasMentioned: boolean | undefined;
|
||||
groupBotLoopProtection: ChannelBotLoopProtectionConfig | undefined;
|
||||
groupSystemPrompt: string | undefined;
|
||||
}
|
||||
| { ok: false }
|
||||
> {
|
||||
const {
|
||||
account,
|
||||
config,
|
||||
core,
|
||||
space,
|
||||
message,
|
||||
isGroup,
|
||||
senderId,
|
||||
senderName,
|
||||
senderEmail,
|
||||
rawBody,
|
||||
statusSink,
|
||||
logVerbose,
|
||||
} = params;
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
|
||||
const spaceId = space.name ?? "";
|
||||
const pairing = createChannelPairingController({
|
||||
core,
|
||||
channel: "googlechat",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
|
||||
const { groupPolicy, providerMissingFallbackApplied } =
|
||||
resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: config.channels?.googlechat !== undefined,
|
||||
groupPolicy: account.config.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied,
|
||||
providerKey: "googlechat",
|
||||
accountId: account.accountId,
|
||||
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.space,
|
||||
log: logVerbose,
|
||||
});
|
||||
warnMutableGroupKeysConfigured(logVerbose, account.config.groups ?? undefined);
|
||||
const groupConfigResolved = resolveGroupConfig({
|
||||
groupId: spaceId,
|
||||
groupName: space.displayName ?? null,
|
||||
groups: account.config.groups ?? undefined,
|
||||
});
|
||||
const groupEntry = groupConfigResolved.entry;
|
||||
const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? [];
|
||||
let effectiveWasMentioned: boolean | undefined;
|
||||
const dmPolicy = account.config.dm?.policy ?? "pairing";
|
||||
const rawConfigAllowFrom = normalizeStringEntries(account.config.dm?.allowFrom);
|
||||
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
|
||||
const groupActivation = (() => {
|
||||
if (!isGroup) {
|
||||
return undefined;
|
||||
}
|
||||
const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true;
|
||||
const mentionInfo = extractMentionInfo(message.annotations ?? [], account.config.botUser);
|
||||
return {
|
||||
requireMention,
|
||||
allowTextCommands: core.channel.commands.shouldHandleTextCommands({
|
||||
cfg: config,
|
||||
surface: "googlechat",
|
||||
}),
|
||||
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
|
||||
wasMentioned: mentionInfo.wasMentioned,
|
||||
hasAnyMention: mentionInfo.hasAnyMention,
|
||||
};
|
||||
})();
|
||||
const command = {
|
||||
hasControlCommand: groupActivation?.hasControlCommand ?? shouldComputeAuth,
|
||||
groupOwnerAllowFrom: "none" as const,
|
||||
};
|
||||
const groupAllowFrom = normalizeStringEntries(groupUsers);
|
||||
const senderGroupPolicy =
|
||||
groupConfigResolved.allowlistConfigured && groupAllowFrom.length === 0
|
||||
? groupPolicy
|
||||
: groupPolicy === "disabled"
|
||||
? "disabled"
|
||||
: groupAllowFrom.length > 0
|
||||
? "allowlist"
|
||||
: "open";
|
||||
const route = channelIngressRoutes(
|
||||
isGroup &&
|
||||
groupPolicy !== "disabled" &&
|
||||
groupEntry?.enabled === false && {
|
||||
id: "googlechat:space",
|
||||
enabled: false,
|
||||
matched: true,
|
||||
matchId: "googlechat-space",
|
||||
blockReason: "route_disabled",
|
||||
},
|
||||
isGroup &&
|
||||
groupPolicy === "allowlist" &&
|
||||
groupEntry?.enabled !== false &&
|
||||
!groupConfigResolved.allowlistConfigured && {
|
||||
id: "googlechat:space",
|
||||
allowed: false,
|
||||
blockReason: "empty_allowlist",
|
||||
},
|
||||
isGroup &&
|
||||
groupPolicy === "allowlist" &&
|
||||
groupEntry?.enabled !== false &&
|
||||
groupConfigResolved.allowlistConfigured && {
|
||||
id: "googlechat:space",
|
||||
senderPolicy: "deny-when-empty" as const,
|
||||
...(groupEntry ? { senderAllowFromSource: "effective-group" as const } : {}),
|
||||
allowed: Boolean(groupEntry),
|
||||
matchId: "googlechat-space",
|
||||
blockReason: groupEntry ? "sender_empty_allowlist" : "route_not_allowlisted",
|
||||
},
|
||||
);
|
||||
const resolvedAccess = await createChannelIngressResolver({
|
||||
channelId: "googlechat",
|
||||
accountId: account.accountId,
|
||||
identity: googleChatIngressIdentity,
|
||||
cfg: config,
|
||||
readStoreAllowFrom: pairing.readAllowFromStore,
|
||||
}).message({
|
||||
subject: {
|
||||
stableId: senderId,
|
||||
aliases: { email: senderEmail },
|
||||
},
|
||||
conversation: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: spaceId,
|
||||
},
|
||||
route,
|
||||
allowFrom: rawConfigAllowFrom,
|
||||
groupAllowFrom,
|
||||
dmPolicy,
|
||||
groupPolicy: senderGroupPolicy,
|
||||
policy: {
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled",
|
||||
...(groupActivation
|
||||
? {
|
||||
activation: {
|
||||
requireMention: groupActivation.requireMention,
|
||||
allowTextCommands: groupActivation.allowTextCommands,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(groupActivation == null
|
||||
? {}
|
||||
: {
|
||||
mentionFacts: {
|
||||
canDetectMention: true,
|
||||
wasMentioned: groupActivation.wasMentioned,
|
||||
hasAnyMention: groupActivation.hasAnyMention,
|
||||
implicitMentionKinds: [],
|
||||
},
|
||||
}),
|
||||
command,
|
||||
});
|
||||
const senderAccess = resolvedAccess.senderAccess;
|
||||
const commandAuthorized = resolvedAccess.commandAccess.requested
|
||||
? resolvedAccess.commandAccess.authorized
|
||||
: undefined;
|
||||
|
||||
if (isGroup) {
|
||||
if (groupConfigResolved.deprecatedNameMatch) {
|
||||
logVerbose(`drop group message (deprecated mutable group key matched, space=${spaceId})`);
|
||||
return { ok: false };
|
||||
}
|
||||
const routeBlockReason = resolvedAccess.routeAccess.reason;
|
||||
if (routeBlockReason && routeBlockReason !== "sender_empty_allowlist") {
|
||||
if (routeBlockReason === "empty_allowlist") {
|
||||
logVerbose(`drop group message (groupPolicy=allowlist, no allowlist, space=${spaceId})`);
|
||||
} else if (routeBlockReason === "route_not_allowlisted") {
|
||||
logVerbose(`drop group message (not allowlisted, space=${spaceId})`);
|
||||
} else if (routeBlockReason === "route_disabled") {
|
||||
logVerbose(`drop group message (space disabled, space=${spaceId})`);
|
||||
}
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (senderAccess.effectiveGroupAllowFrom.length > 0 && senderAccess.decision !== "allow") {
|
||||
warnDeprecatedUsersEmailEntries(logVerbose, senderAccess.effectiveGroupAllowFrom);
|
||||
logVerbose(`drop group message (sender not allowed, ${senderId})`);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveAllowFrom = senderAccess.effectiveAllowFrom;
|
||||
warnDeprecatedUsersEmailEntries(logVerbose, effectiveAllowFrom);
|
||||
|
||||
if (isGroup && resolvedAccess.activationAccess.ran) {
|
||||
effectiveWasMentioned = resolvedAccess.activationAccess.effectiveWasMentioned;
|
||||
if (resolvedAccess.activationAccess.shouldSkip) {
|
||||
logVerbose(`drop group message (mention required, space=${spaceId})`);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (isGroup && senderAccess.decision !== "allow") {
|
||||
const reason =
|
||||
resolvedAccess.ingress.reasonCode === "route_sender_empty"
|
||||
? "groupPolicy=allowlist (empty allowlist)"
|
||||
: senderAccess.reasonCode;
|
||||
logVerbose(`drop group message (sender policy blocked, reason=${reason}, space=${spaceId})`);
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (!isGroup) {
|
||||
if (account.config.dm?.enabled === false) {
|
||||
logVerbose(`Blocked Google Chat DM from ${senderId} (dmPolicy=disabled)`);
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (senderAccess.decision !== "allow") {
|
||||
if (senderAccess.decision === "pairing") {
|
||||
await pairing.issueChallenge({
|
||||
senderId,
|
||||
senderIdLine: `Your Google Chat user id: ${senderId}`,
|
||||
meta: { name: senderName || undefined, email: senderEmail },
|
||||
onCreated: () => {
|
||||
logVerbose(`googlechat pairing request sender=${senderId}`);
|
||||
},
|
||||
sendPairingReply: async (text) => {
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: spaceId,
|
||||
text,
|
||||
});
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
logVerbose(`pairing reply failed for ${senderId}: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
logVerbose(`Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`);
|
||||
}
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isGroup &&
|
||||
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
||||
commandAuthorized !== true
|
||||
) {
|
||||
logVerbose(`googlechat: drop control command from ${senderId}`);
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
commandAuthorized,
|
||||
effectiveWasMentioned,
|
||||
groupBotLoopProtection: groupEntry?.botLoopProtection,
|
||||
groupSystemPrompt: normalizeOptionalString(groupEntry?.systemPrompt),
|
||||
};
|
||||
}
|
||||
40
extensions/googlechat/src/monitor-durable.test.ts
Normal file
40
extensions/googlechat/src/monitor-durable.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// Googlechat tests cover monitor durable plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveGoogleChatDurableReplyOptions } from "./monitor-durable.js";
|
||||
|
||||
describe("resolveGoogleChatDurableReplyOptions", () => {
|
||||
it("enables durable final delivery when no typing preview is active", () => {
|
||||
expect(
|
||||
resolveGoogleChatDurableReplyOptions({
|
||||
payload: { text: "hello", replyToId: "thread-1" },
|
||||
infoKind: "final",
|
||||
spaceId: "spaces/AAA",
|
||||
}),
|
||||
).toEqual({
|
||||
to: "spaces/AAA",
|
||||
replyToId: "thread-1",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps typing preview delivery on the legacy edit path", () => {
|
||||
expect(
|
||||
resolveGoogleChatDurableReplyOptions({
|
||||
payload: { text: "hello" },
|
||||
infoKind: "final",
|
||||
spaceId: "spaces/AAA",
|
||||
typingMessageName: "spaces/AAA/messages/typing",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not durable-deliver non-final chunks", () => {
|
||||
expect(
|
||||
resolveGoogleChatDurableReplyOptions({
|
||||
payload: { text: "hello" },
|
||||
infoKind: "block",
|
||||
spaceId: "spaces/AAA",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
24
extensions/googlechat/src/monitor-durable.ts
Normal file
24
extensions/googlechat/src/monitor-durable.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Googlechat plugin module implements monitor durable behavior.
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
|
||||
export type GoogleChatDurableReplyOptions = {
|
||||
to: string;
|
||||
replyToId?: string;
|
||||
threadId?: string;
|
||||
};
|
||||
|
||||
export function resolveGoogleChatDurableReplyOptions(params: {
|
||||
payload: ReplyPayload;
|
||||
infoKind: string;
|
||||
spaceId: string;
|
||||
typingMessageName?: string;
|
||||
}): GoogleChatDurableReplyOptions | false {
|
||||
if (params.infoKind !== "final" || params.typingMessageName) {
|
||||
return false;
|
||||
}
|
||||
const threadId = params.payload.replyToId?.trim() || undefined;
|
||||
return {
|
||||
to: params.spaceId,
|
||||
...(threadId ? { replyToId: threadId, threadId } : {}),
|
||||
};
|
||||
}
|
||||
162
extensions/googlechat/src/monitor-reply-delivery.ts
Normal file
162
extensions/googlechat/src/monitor-reply-delivery.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// Googlechat plugin module implements monitor reply delivery behavior.
|
||||
import {
|
||||
deliverTextOrMediaReply,
|
||||
resolveSendableOutboundReplyParts,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import {
|
||||
deleteGoogleChatMessage,
|
||||
sendGoogleChatMessage,
|
||||
updateGoogleChatMessage,
|
||||
uploadGoogleChatAttachment,
|
||||
} from "./api.js";
|
||||
import type { GoogleChatCoreRuntime, GoogleChatRuntimeEnv } from "./monitor-types.js";
|
||||
|
||||
export async function deliverGoogleChatReply(params: {
|
||||
payload: {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
mediaUrl?: string;
|
||||
replyToId?: string;
|
||||
};
|
||||
account: ResolvedGoogleChatAccount;
|
||||
spaceId: string;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
core: GoogleChatCoreRuntime;
|
||||
config: OpenClawConfig;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
typingMessageName?: string;
|
||||
}): Promise<void> {
|
||||
const { payload, account, spaceId, runtime, core, config, statusSink } = params;
|
||||
// Clear this whenever the typing message is deleted or unavailable; otherwise
|
||||
// text delivery can keep retrying a dead message and drop content.
|
||||
let typingMessageName = params.typingMessageName;
|
||||
const reply = resolveSendableOutboundReplyParts(payload);
|
||||
const mediaCount = reply.mediaCount;
|
||||
const hasMedia = reply.hasMedia;
|
||||
const text = reply.text;
|
||||
let firstTextChunk = true;
|
||||
let suppressCaption = false;
|
||||
|
||||
if (hasMedia && typingMessageName) {
|
||||
try {
|
||||
await deleteGoogleChatMessage({
|
||||
account,
|
||||
messageName: typingMessageName,
|
||||
});
|
||||
typingMessageName = undefined;
|
||||
} catch (err) {
|
||||
runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
|
||||
if (typingMessageName) {
|
||||
const fallbackText = reply.hasText
|
||||
? text
|
||||
: mediaCount > 1
|
||||
? "Sent attachments."
|
||||
: "Sent attachment.";
|
||||
try {
|
||||
await updateGoogleChatMessage({
|
||||
account,
|
||||
messageName: typingMessageName,
|
||||
text: fallbackText,
|
||||
});
|
||||
suppressCaption = Boolean(text.trim());
|
||||
} catch (updateErr) {
|
||||
runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`);
|
||||
typingMessageName = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chunkLimit = account.config.textChunkLimit ?? 4000;
|
||||
const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId);
|
||||
const sendTextMessage = async (chunk: string) => {
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: spaceId,
|
||||
text: chunk,
|
||||
thread: payload.replyToId,
|
||||
});
|
||||
};
|
||||
await deliverTextOrMediaReply({
|
||||
payload,
|
||||
text: suppressCaption ? "" : reply.text,
|
||||
chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode),
|
||||
sendText: async (chunk) => {
|
||||
try {
|
||||
if (firstTextChunk && typingMessageName) {
|
||||
await updateGoogleChatMessage({
|
||||
account,
|
||||
messageName: typingMessageName,
|
||||
text: chunk,
|
||||
});
|
||||
} else {
|
||||
await sendTextMessage(chunk);
|
||||
}
|
||||
firstTextChunk = false;
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error?.(`Google Chat message send failed: ${String(err)}`);
|
||||
if (firstTextChunk && typingMessageName) {
|
||||
typingMessageName = undefined;
|
||||
try {
|
||||
await sendTextMessage(chunk);
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (fallbackErr) {
|
||||
runtime.error?.(`Google Chat message fallback send failed: ${String(fallbackErr)}`);
|
||||
} finally {
|
||||
firstTextChunk = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
sendMedia: async ({ mediaUrl, caption }) => {
|
||||
try {
|
||||
const loaded = await core.channel.media.readRemoteMediaBuffer({
|
||||
url: mediaUrl,
|
||||
maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
|
||||
});
|
||||
const upload = await uploadAttachmentForReply({
|
||||
account,
|
||||
spaceId,
|
||||
buffer: loaded.buffer,
|
||||
contentType: loaded.contentType,
|
||||
filename: loaded.fileName ?? "attachment",
|
||||
});
|
||||
if (!upload.attachmentUploadToken) {
|
||||
throw new Error("missing attachment upload token");
|
||||
}
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: spaceId,
|
||||
text: caption,
|
||||
thread: payload.replyToId,
|
||||
attachments: [
|
||||
{ attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.fileName },
|
||||
],
|
||||
});
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error?.(`Google Chat attachment send failed: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadAttachmentForReply(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
spaceId: string;
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
filename: string;
|
||||
}) {
|
||||
const { account, spaceId, buffer, contentType, filename } = params;
|
||||
return await uploadGoogleChatAttachment({
|
||||
account,
|
||||
space: spaceId,
|
||||
filename,
|
||||
buffer,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
66
extensions/googlechat/src/monitor-routing.ts
Normal file
66
extensions/googlechat/src/monitor-routing.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// Googlechat plugin module implements monitor routing behavior.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import {
|
||||
createFixedWindowRateLimiter,
|
||||
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { createWebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import { registerWebhookTargetWithPluginRoute } from "openclaw/plugin-sdk/webhook-targets";
|
||||
import type { WebhookTarget } from "./monitor-types.js";
|
||||
import { createGoogleChatWebhookRequestHandler } from "./monitor-webhook.js";
|
||||
import type { GoogleChatEvent } from "./types.js";
|
||||
|
||||
type ProcessGoogleChatEvent = (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
|
||||
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||
const webhookRateLimiter = createFixedWindowRateLimiter({
|
||||
windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs,
|
||||
maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests,
|
||||
maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys,
|
||||
});
|
||||
const webhookInFlightLimiter = createWebhookInFlightLimiter();
|
||||
|
||||
let processGoogleChatEvent: ProcessGoogleChatEvent = async () => {};
|
||||
|
||||
export function setGoogleChatWebhookEventProcessor(processEvent: ProcessGoogleChatEvent): void {
|
||||
processGoogleChatEvent = processEvent;
|
||||
}
|
||||
|
||||
const googleChatWebhookRequestHandler = createGoogleChatWebhookRequestHandler({
|
||||
webhookTargets,
|
||||
webhookRateLimiter,
|
||||
webhookInFlightLimiter,
|
||||
processEvent: async (event, target) => {
|
||||
await processGoogleChatEvent(event, target);
|
||||
},
|
||||
});
|
||||
|
||||
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
|
||||
return registerWebhookTargetWithPluginRoute({
|
||||
targetsByPath: webhookTargets,
|
||||
target,
|
||||
route: {
|
||||
auth: "plugin",
|
||||
match: "exact",
|
||||
pluginId: "googlechat",
|
||||
source: "googlechat-webhook",
|
||||
accountId: target.account.accountId,
|
||||
log: target.runtime.log,
|
||||
handler: async (req, res) => {
|
||||
const handled = await handleGoogleChatWebhookRequest(req, res);
|
||||
if (!handled && !res.headersSent) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Not Found");
|
||||
}
|
||||
},
|
||||
},
|
||||
}).unregister;
|
||||
}
|
||||
|
||||
export async function handleGoogleChatWebhookRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
return await googleChatWebhookRequestHandler(req, res);
|
||||
}
|
||||
34
extensions/googlechat/src/monitor-types.ts
Normal file
34
extensions/googlechat/src/monitor-types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Googlechat plugin module implements monitor types behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import type { GoogleChatAudienceType } from "./auth.js";
|
||||
import type { getGoogleChatRuntime } from "./runtime.js";
|
||||
|
||||
export type GoogleChatRuntimeEnv = {
|
||||
log?: (message: string) => void;
|
||||
error?: (message: string) => void;
|
||||
};
|
||||
|
||||
export type GoogleChatMonitorOptions = {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
config: OpenClawConfig;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
abortSignal: AbortSignal;
|
||||
webhookPath?: string;
|
||||
webhookUrl?: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
};
|
||||
|
||||
export type GoogleChatCoreRuntime = ReturnType<typeof getGoogleChatRuntime>;
|
||||
|
||||
export type WebhookTarget = {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
config: OpenClawConfig;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
core: GoogleChatCoreRuntime;
|
||||
path: string;
|
||||
audienceType?: GoogleChatAudienceType;
|
||||
audience?: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
mediaMaxMb: number;
|
||||
};
|
||||
670
extensions/googlechat/src/monitor-webhook.test.ts
Normal file
670
extensions/googlechat/src/monitor-webhook.test.ts
Normal file
@@ -0,0 +1,670 @@
|
||||
// Googlechat tests cover monitor webhook plugin behavior.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { FixedWindowRateLimiter } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WebhookTarget } from "./monitor-types.js";
|
||||
import type { GoogleChatEvent } from "./types.js";
|
||||
|
||||
const readJsonWebhookBodyOrReject = vi.hoisted(() => vi.fn());
|
||||
const resolveWebhookTargetWithAuthOrReject = vi.hoisted(() => vi.fn());
|
||||
const withResolvedWebhookRequestPipeline = vi.hoisted(() => vi.fn());
|
||||
const verifyGoogleChatRequest = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/webhook-request-guards", () => ({
|
||||
readJsonWebhookBodyOrReject,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/webhook-targets", () => ({
|
||||
resolveWebhookTargetWithAuthOrReject,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
}));
|
||||
|
||||
vi.mock("./auth.js", () => ({
|
||||
verifyGoogleChatRequest,
|
||||
}));
|
||||
|
||||
type ProcessEventFn = (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
|
||||
let createGoogleChatWebhookRequestHandler: typeof import("./monitor-webhook.js").createGoogleChatWebhookRequestHandler;
|
||||
let warnAppPrincipalMisconfiguration: typeof import("./monitor-webhook.js").warnAppPrincipalMisconfiguration;
|
||||
|
||||
function createRequest(options?: {
|
||||
authorization?: string;
|
||||
headers?: Record<string, string>;
|
||||
remoteAddress?: string;
|
||||
url?: string;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
method: "POST",
|
||||
url: options?.url ?? "/googlechat",
|
||||
headers: {
|
||||
authorization: options?.authorization ?? "",
|
||||
"content-type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
socket: { remoteAddress: options?.remoteAddress ?? "203.0.113.10" },
|
||||
} as IncomingMessage;
|
||||
}
|
||||
|
||||
function createResponse() {
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
headers: {} as Record<string, string>,
|
||||
body: "",
|
||||
setHeader: (name: string, value: string) => {
|
||||
res.headers[name] = value;
|
||||
},
|
||||
end: (payload?: string) => {
|
||||
res.body = payload ?? "";
|
||||
return res;
|
||||
},
|
||||
} as ServerResponse & { headers: Record<string, string>; body: string };
|
||||
return res;
|
||||
}
|
||||
|
||||
function installSimplePipeline(targets: unknown[]) {
|
||||
withResolvedWebhookRequestPipeline.mockImplementation(
|
||||
async ({
|
||||
handle,
|
||||
req,
|
||||
res,
|
||||
}: {
|
||||
handle: (input: {
|
||||
targets: unknown[];
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
}) => Promise<unknown>;
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
}) =>
|
||||
await handle({
|
||||
targets,
|
||||
req,
|
||||
res,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function runWebhookHandler(options?: {
|
||||
processEvent?: ProcessEventFn;
|
||||
authorization?: string;
|
||||
webhookRateLimiter?: FixedWindowRateLimiter;
|
||||
}) {
|
||||
const processEvent: ProcessEventFn =
|
||||
options?.processEvent ?? (vi.fn(async () => {}) as ProcessEventFn);
|
||||
const handler = createGoogleChatWebhookRequestHandler({
|
||||
webhookTargets: new Map(),
|
||||
webhookRateLimiter: options?.webhookRateLimiter ?? {
|
||||
isRateLimited: vi.fn(() => false),
|
||||
size: vi.fn(() => 0),
|
||||
clear: vi.fn(),
|
||||
},
|
||||
webhookInFlightLimiter: {} as never,
|
||||
processEvent,
|
||||
});
|
||||
const req = createRequest({ authorization: options?.authorization });
|
||||
const res = createResponse();
|
||||
await expect(handler(req, res)).resolves.toBe(true);
|
||||
return { processEvent, res };
|
||||
}
|
||||
|
||||
describe("googlechat monitor webhook", () => {
|
||||
beforeAll(async () => {
|
||||
({ createGoogleChatWebhookRequestHandler, warnAppPrincipalMisconfiguration } =
|
||||
await import("./monitor-webhook.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/webhook-request-guards");
|
||||
vi.doUnmock("openclaw/plugin-sdk/webhook-targets");
|
||||
vi.doUnmock("./auth.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("passes a fixed-window request limiter to the shared webhook pipeline", async () => {
|
||||
const rateLimiter: FixedWindowRateLimiter = {
|
||||
isRateLimited: vi.fn(() => false),
|
||||
size: vi.fn(() => 0),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>([
|
||||
[
|
||||
"/googlechat",
|
||||
[
|
||||
{
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: { appPrincipal: "chat-app" },
|
||||
},
|
||||
config: {
|
||||
gateway: {
|
||||
trustedProxies: ["10.0.0.0/24"],
|
||||
},
|
||||
},
|
||||
runtime: {},
|
||||
core: {} as never,
|
||||
path: "/googlechat",
|
||||
mediaMaxMb: 20,
|
||||
} as unknown as WebhookTarget,
|
||||
],
|
||||
],
|
||||
]);
|
||||
const webhookInFlightLimiter = {} as never;
|
||||
const processEvent = vi.fn(async () => {});
|
||||
const handler = createGoogleChatWebhookRequestHandler({
|
||||
webhookTargets,
|
||||
webhookRateLimiter: rateLimiter,
|
||||
webhookInFlightLimiter,
|
||||
processEvent,
|
||||
});
|
||||
const req = createRequest({
|
||||
url: "/googlechat?ignored=1",
|
||||
headers: {
|
||||
"x-forwarded-for": "198.51.100.7, 10.0.0.1",
|
||||
},
|
||||
remoteAddress: "10.0.0.1",
|
||||
});
|
||||
const res = createResponse();
|
||||
withResolvedWebhookRequestPipeline.mockResolvedValue(true);
|
||||
|
||||
await expect(handler(req, res)).resolves.toBe(true);
|
||||
|
||||
expect(withResolvedWebhookRequestPipeline).toHaveBeenCalledWith({
|
||||
req,
|
||||
res,
|
||||
targetsByPath: webhookTargets,
|
||||
allowMethods: ["POST"],
|
||||
requireJsonContentType: true,
|
||||
rateLimiter,
|
||||
rateLimitKey: "/googlechat:198.51.100.7",
|
||||
inFlightLimiter: webhookInFlightLimiter,
|
||||
handle: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the unknown rate-limit bucket when a trusted proxy omits client headers", async () => {
|
||||
const rateLimiter: FixedWindowRateLimiter = {
|
||||
isRateLimited: vi.fn(() => false),
|
||||
size: vi.fn(() => 0),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>([
|
||||
[
|
||||
"/googlechat",
|
||||
[
|
||||
{
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: { appPrincipal: "chat-app" },
|
||||
},
|
||||
config: {
|
||||
gateway: {
|
||||
trustedProxies: ["10.0.0.0/24"],
|
||||
},
|
||||
},
|
||||
runtime: {},
|
||||
core: {} as never,
|
||||
path: "/googlechat",
|
||||
mediaMaxMb: 20,
|
||||
} as unknown as WebhookTarget,
|
||||
],
|
||||
],
|
||||
]);
|
||||
const webhookInFlightLimiter = {} as never;
|
||||
const processEvent = vi.fn(async () => {});
|
||||
const handler = createGoogleChatWebhookRequestHandler({
|
||||
webhookTargets,
|
||||
webhookRateLimiter: rateLimiter,
|
||||
webhookInFlightLimiter,
|
||||
processEvent,
|
||||
});
|
||||
const req = createRequest({ remoteAddress: "10.0.0.1" });
|
||||
const res = createResponse();
|
||||
withResolvedWebhookRequestPipeline.mockResolvedValue(true);
|
||||
|
||||
await expect(handler(req, res)).resolves.toBe(true);
|
||||
|
||||
expect(withResolvedWebhookRequestPipeline).toHaveBeenCalledWith({
|
||||
req,
|
||||
res,
|
||||
targetsByPath: webhookTargets,
|
||||
allowMethods: ["POST"],
|
||||
requireJsonContentType: true,
|
||||
rateLimiter,
|
||||
rateLimitKey: "/googlechat:unknown",
|
||||
inFlightLimiter: webhookInFlightLimiter,
|
||||
handle: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts add-on payloads that carry systemIdToken in the body", async () => {
|
||||
const target = {
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: { appPrincipal: "chat-app" },
|
||||
},
|
||||
runtime: { error: vi.fn() },
|
||||
statusSink: vi.fn(),
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
};
|
||||
installSimplePipeline([target]);
|
||||
readJsonWebhookBodyOrReject.mockResolvedValue({
|
||||
ok: true,
|
||||
value: {
|
||||
commonEventObject: { hostApp: "CHAT" },
|
||||
authorizationEventObject: { systemIdToken: "addon-token" },
|
||||
chat: {
|
||||
eventTime: "2026-03-22T00:00:00.000Z",
|
||||
user: { name: "users/123" },
|
||||
messagePayload: {
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { name: "spaces/AAA/messages/1", text: "hello" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets }) => {
|
||||
for (const targetLocal of targets) {
|
||||
if (await isMatch(targetLocal)) {
|
||||
return targetLocal;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
verifyGoogleChatRequest.mockResolvedValue({ ok: true });
|
||||
const { processEvent, res } = await runWebhookHandler();
|
||||
|
||||
expect(verifyGoogleChatRequest).toHaveBeenCalledWith({
|
||||
bearer: "addon-token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
expectedAddOnPrincipal: "chat-app",
|
||||
});
|
||||
expect(processEvent).toHaveBeenCalledWith(
|
||||
{
|
||||
type: "MESSAGE",
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { name: "spaces/AAA/messages/1", text: "hello" },
|
||||
user: { name: "users/123" },
|
||||
eventTime: "2026-03-22T00:00:00.000Z",
|
||||
},
|
||||
target,
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["Content-Type"]).toBe("application/json");
|
||||
expect(res.body).toBe("{}");
|
||||
});
|
||||
|
||||
it("normalizes add-on card-click payloads for approval actions", async () => {
|
||||
const target = {
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: { appPrincipal: "chat-app" },
|
||||
},
|
||||
runtime: { error: vi.fn() },
|
||||
statusSink: vi.fn(),
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
};
|
||||
installSimplePipeline([target]);
|
||||
readJsonWebhookBodyOrReject.mockResolvedValue({
|
||||
ok: true,
|
||||
value: {
|
||||
commonEventObject: {
|
||||
hostApp: "CHAT",
|
||||
parameters: {
|
||||
openclaw_action: "approval",
|
||||
token: "token-1",
|
||||
},
|
||||
},
|
||||
authorizationEventObject: { systemIdToken: "addon-token" },
|
||||
chat: {
|
||||
eventTime: "2026-03-22T00:00:00.000Z",
|
||||
user: { name: "users/123" },
|
||||
buttonClickedPayload: {
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { name: "spaces/AAA/messages/1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets }) => {
|
||||
for (const targetLocal of targets) {
|
||||
if (await isMatch(targetLocal)) {
|
||||
return targetLocal;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
verifyGoogleChatRequest.mockResolvedValue({ ok: true });
|
||||
const { processEvent, res } = await runWebhookHandler();
|
||||
|
||||
expect(verifyGoogleChatRequest).toHaveBeenCalledWith({
|
||||
bearer: "addon-token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
expectedAddOnPrincipal: "chat-app",
|
||||
});
|
||||
expect(processEvent).toHaveBeenCalledWith(
|
||||
{
|
||||
type: "CARD_CLICKED",
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { name: "spaces/AAA/messages/1" },
|
||||
user: { name: "users/123" },
|
||||
eventTime: "2026-03-22T00:00:00.000Z",
|
||||
action: {
|
||||
parameters: [
|
||||
{ key: "openclaw_action", value: "approval" },
|
||||
{ key: "token", value: "token-1" },
|
||||
],
|
||||
},
|
||||
commonEventObject: {
|
||||
parameters: {
|
||||
openclaw_action: "approval",
|
||||
token: "token-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
target,
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["Content-Type"]).toBe("application/json");
|
||||
expect(res.body).toBe("{}");
|
||||
});
|
||||
|
||||
it("logs WARN with reason when verification fails (missing token)", async () => {
|
||||
const logFn = vi.fn();
|
||||
installSimplePipeline([
|
||||
{
|
||||
account: {
|
||||
accountId: "acct-1",
|
||||
config: { appPrincipal: "chat-app" },
|
||||
},
|
||||
runtime: { log: logFn, error: vi.fn() },
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
]);
|
||||
readJsonWebhookBodyOrReject.mockResolvedValue({
|
||||
ok: true,
|
||||
value: {
|
||||
commonEventObject: { hostApp: "CHAT" },
|
||||
authorizationEventObject: { systemIdToken: "bad-token" },
|
||||
chat: {
|
||||
messagePayload: {
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { name: "spaces/AAA/messages/1", text: "hi" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets, res }) => {
|
||||
for (const target of targets) {
|
||||
if (await isMatch(target)) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
return null;
|
||||
});
|
||||
verifyGoogleChatRequest.mockResolvedValue({ ok: false, reason: "missing token" });
|
||||
const { processEvent, res } = await runWebhookHandler();
|
||||
|
||||
expect(logFn).toHaveBeenCalledWith("[acct-1] Google Chat webhook auth rejected: missing token");
|
||||
expect(processEvent).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("logs WARN with reason when verification fails (unexpected principal)", async () => {
|
||||
const logFn = vi.fn();
|
||||
installSimplePipeline([
|
||||
{
|
||||
account: {
|
||||
accountId: "acct-2",
|
||||
config: { appPrincipal: "chat-app" },
|
||||
},
|
||||
runtime: { log: logFn, error: vi.fn() },
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
]);
|
||||
readJsonWebhookBodyOrReject.mockResolvedValue({
|
||||
ok: true,
|
||||
value: {
|
||||
commonEventObject: { hostApp: "CHAT" },
|
||||
authorizationEventObject: { systemIdToken: "bad-token" },
|
||||
chat: {
|
||||
messagePayload: {
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { name: "spaces/AAA/messages/1", text: "hi" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets, res }) => {
|
||||
for (const target of targets) {
|
||||
if (await isMatch(target)) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
return null;
|
||||
});
|
||||
verifyGoogleChatRequest.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "unexpected add-on principal: 999999999999999999999",
|
||||
});
|
||||
const { processEvent, res } = await runWebhookHandler();
|
||||
|
||||
expect(logFn).toHaveBeenCalledWith(
|
||||
"[acct-2] Google Chat webhook auth rejected: unexpected add-on principal: 999999999999999999999",
|
||||
);
|
||||
expect(processEvent).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("does not log WARN when verification succeeds", async () => {
|
||||
const logFn = vi.fn();
|
||||
installSimplePipeline([
|
||||
{
|
||||
account: {
|
||||
accountId: "acct-ok",
|
||||
config: { appPrincipal: "chat-app" },
|
||||
},
|
||||
runtime: { log: logFn, error: vi.fn() },
|
||||
statusSink: vi.fn(),
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
]);
|
||||
readJsonWebhookBodyOrReject.mockResolvedValue({
|
||||
ok: true,
|
||||
value: {
|
||||
commonEventObject: { hostApp: "CHAT" },
|
||||
authorizationEventObject: { systemIdToken: "good-token" },
|
||||
chat: {
|
||||
eventTime: "2026-03-22T00:00:00.000Z",
|
||||
user: { name: "users/123" },
|
||||
messagePayload: {
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { name: "spaces/AAA/messages/1", text: "hi" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets }) => {
|
||||
for (const target of targets) {
|
||||
if (await isMatch(target)) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
verifyGoogleChatRequest.mockResolvedValue({ ok: true });
|
||||
const { res } = await runWebhookHandler();
|
||||
|
||||
expect(logFn).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["Content-Type"]).toBe("application/json");
|
||||
expect(res.body).toBe("{}");
|
||||
});
|
||||
|
||||
it("does not log failed candidate targets when another target verifies", async () => {
|
||||
const logA = vi.fn();
|
||||
const logB = vi.fn();
|
||||
const targetA = {
|
||||
account: {
|
||||
accountId: "acct-a",
|
||||
config: { appPrincipal: "chat-app-a" },
|
||||
},
|
||||
runtime: { log: logA, error: vi.fn() },
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
};
|
||||
const targetB = {
|
||||
account: {
|
||||
accountId: "acct-b",
|
||||
config: { appPrincipal: "chat-app-b" },
|
||||
},
|
||||
runtime: { log: logB, error: vi.fn() },
|
||||
statusSink: vi.fn(),
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
};
|
||||
installSimplePipeline([targetA, targetB]);
|
||||
readJsonWebhookBodyOrReject.mockResolvedValue({
|
||||
ok: true,
|
||||
value: {
|
||||
commonEventObject: { hostApp: "CHAT" },
|
||||
authorizationEventObject: { systemIdToken: "shared-path-token" },
|
||||
chat: {
|
||||
eventTime: "2026-03-22T00:00:00.000Z",
|
||||
user: { name: "users/123" },
|
||||
messagePayload: {
|
||||
space: { name: "spaces/BBB" },
|
||||
message: { name: "spaces/BBB/messages/1", text: "hi" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets }) => {
|
||||
for (const target of targets) {
|
||||
if (await isMatch(target)) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
verifyGoogleChatRequest
|
||||
.mockResolvedValueOnce({ ok: false, reason: "unexpected add-on principal: 111" })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
const { processEvent, res } = await runWebhookHandler();
|
||||
|
||||
expect(logA).not.toHaveBeenCalled();
|
||||
expect(logB).not.toHaveBeenCalled();
|
||||
expect(processEvent).toHaveBeenCalledWith(
|
||||
{
|
||||
type: "MESSAGE",
|
||||
space: { name: "spaces/BBB" },
|
||||
message: { name: "spaces/BBB/messages/1", text: "hi" },
|
||||
user: { name: "users/123" },
|
||||
eventTime: "2026-03-22T00:00:00.000Z",
|
||||
},
|
||||
targetB,
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["Content-Type"]).toBe("application/json");
|
||||
expect(res.body).toBe("{}");
|
||||
});
|
||||
|
||||
it("rejects missing add-on bearer tokens before dispatch", async () => {
|
||||
const logFn = vi.fn();
|
||||
installSimplePipeline([
|
||||
{
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: { appPrincipal: "chat-app" },
|
||||
},
|
||||
runtime: { log: logFn, error: vi.fn() },
|
||||
},
|
||||
]);
|
||||
readJsonWebhookBodyOrReject.mockResolvedValue({
|
||||
ok: true,
|
||||
value: {
|
||||
commonEventObject: { hostApp: "CHAT" },
|
||||
chat: {
|
||||
messagePayload: {
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { name: "spaces/AAA/messages/1", text: "hello" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const { processEvent, res } = await runWebhookHandler();
|
||||
|
||||
expect(processEvent).not.toHaveBeenCalled();
|
||||
expect(logFn).toHaveBeenCalledWith(
|
||||
"[default] Google Chat webhook auth rejected: missing token",
|
||||
);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body).toBe("unauthorized");
|
||||
});
|
||||
});
|
||||
|
||||
describe("warnAppPrincipalMisconfiguration", () => {
|
||||
it("warns when appPrincipal is missing for app-url audience", () => {
|
||||
const log = vi.fn();
|
||||
warnAppPrincipalMisconfiguration({
|
||||
accountId: "acct-missing",
|
||||
audienceType: "app-url",
|
||||
appPrincipal: undefined,
|
||||
log,
|
||||
});
|
||||
expect(log).toHaveBeenCalledOnce();
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
'[acct-missing] appPrincipal is missing for audienceType "app-url"; add-on token verification will fail. Set appPrincipal to the numeric OAuth 2.0 client ID (uniqueId, 21 digits), not an email.',
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when appPrincipal contains @ for app-url audience", () => {
|
||||
const log = vi.fn();
|
||||
warnAppPrincipalMisconfiguration({
|
||||
accountId: "acct-email",
|
||||
audienceType: "app-url",
|
||||
appPrincipal: "bot@example.iam.gserviceaccount.com",
|
||||
log,
|
||||
});
|
||||
expect(log).toHaveBeenCalledOnce();
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
'[acct-email] appPrincipal "bot@example.iam.gserviceaccount.com" looks like an email address. Set appPrincipal to the numeric OAuth 2.0 client ID (uniqueId, 21 digits), not an email.',
|
||||
);
|
||||
});
|
||||
|
||||
it("does not warn for valid numeric appPrincipal with app-url audience", () => {
|
||||
const log = vi.fn();
|
||||
warnAppPrincipalMisconfiguration({
|
||||
accountId: "acct-ok",
|
||||
audienceType: "app-url",
|
||||
appPrincipal: "123456789012345678901",
|
||||
log,
|
||||
});
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not warn for project-number audience even with missing appPrincipal", () => {
|
||||
const log = vi.fn();
|
||||
warnAppPrincipalMisconfiguration({
|
||||
accountId: "acct-pn",
|
||||
audienceType: "project-number",
|
||||
appPrincipal: undefined,
|
||||
log,
|
||||
});
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
361
extensions/googlechat/src/monitor-webhook.ts
Normal file
361
extensions/googlechat/src/monitor-webhook.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
// Googlechat plugin module implements monitor webhook behavior.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
normalizeWebhookPath,
|
||||
resolveRequestClientIp,
|
||||
type FixedWindowRateLimiter,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import type { WebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import { readJsonWebhookBodyOrReject } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import {
|
||||
resolveWebhookTargetWithAuthOrReject,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
} from "openclaw/plugin-sdk/webhook-targets";
|
||||
import { verifyGoogleChatRequest } from "./auth.js";
|
||||
import type { WebhookTarget } from "./monitor-types.js";
|
||||
import type {
|
||||
GoogleChatAction,
|
||||
GoogleChatActionParameter,
|
||||
GoogleChatEvent,
|
||||
GoogleChatMessage,
|
||||
GoogleChatSpace,
|
||||
GoogleChatUser,
|
||||
} from "./types.js";
|
||||
|
||||
function extractBearerToken(header: unknown): string {
|
||||
const authHeader = Array.isArray(header)
|
||||
? typeof header[0] === "string"
|
||||
? header[0]
|
||||
: ""
|
||||
: typeof header === "string"
|
||||
? header
|
||||
: "";
|
||||
return normalizeLowercaseStringOrEmpty(authHeader).startsWith("bearer ")
|
||||
? authHeader.slice("bearer ".length).trim()
|
||||
: "";
|
||||
}
|
||||
|
||||
const ADD_ON_PREAUTH_MAX_BYTES = 16 * 1024;
|
||||
const ADD_ON_PREAUTH_TIMEOUT_MS = 3_000;
|
||||
|
||||
type ParsedGoogleChatInboundPayload =
|
||||
| { ok: true; event: GoogleChatEvent; addOnBearerToken: string }
|
||||
| { ok: false };
|
||||
type ParsedGoogleChatInboundSuccess = Extract<ParsedGoogleChatInboundPayload, { ok: true }>;
|
||||
|
||||
function recordParamsToActionParameters(
|
||||
params?: Record<string, string>,
|
||||
): GoogleChatActionParameter[] | undefined {
|
||||
if (!params) {
|
||||
return undefined;
|
||||
}
|
||||
const entries = Object.entries(params)
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
||||
.map(([key, value]) => ({ key, value }));
|
||||
return entries.length > 0 ? entries : undefined;
|
||||
}
|
||||
|
||||
function parseGoogleChatInboundPayload(
|
||||
raw: unknown,
|
||||
res: ServerResponse,
|
||||
): ParsedGoogleChatInboundPayload {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
let eventPayload = raw;
|
||||
let addOnBearerToken = "";
|
||||
|
||||
// Transform Google Workspace Add-on format to standard Chat API format.
|
||||
const rawObj = raw as {
|
||||
commonEventObject?: {
|
||||
hostApp?: string;
|
||||
invokedFunction?: string;
|
||||
parameters?: Record<string, string>;
|
||||
};
|
||||
chat?: {
|
||||
messagePayload?: { space?: GoogleChatSpace; message?: GoogleChatMessage };
|
||||
buttonClickedPayload?: {
|
||||
space?: GoogleChatSpace;
|
||||
message?: GoogleChatMessage;
|
||||
user?: GoogleChatUser;
|
||||
action?: GoogleChatAction;
|
||||
};
|
||||
user?: GoogleChatUser;
|
||||
eventTime?: string;
|
||||
};
|
||||
authorizationEventObject?: { systemIdToken?: string };
|
||||
};
|
||||
|
||||
if (rawObj.commonEventObject?.hostApp === "CHAT") {
|
||||
addOnBearerToken =
|
||||
typeof rawObj.authorizationEventObject?.systemIdToken === "string"
|
||||
? rawObj.authorizationEventObject.systemIdToken.trim()
|
||||
: "";
|
||||
}
|
||||
|
||||
if (rawObj.commonEventObject?.hostApp === "CHAT" && rawObj.chat?.messagePayload) {
|
||||
const chat = rawObj.chat;
|
||||
const messagePayload = chat.messagePayload;
|
||||
eventPayload = {
|
||||
type: "MESSAGE",
|
||||
space: messagePayload?.space,
|
||||
message: messagePayload?.message,
|
||||
user: chat.user,
|
||||
eventTime: chat.eventTime,
|
||||
};
|
||||
} else if (rawObj.commonEventObject?.hostApp === "CHAT") {
|
||||
const chat = rawObj.chat;
|
||||
const buttonClickedPayload = chat?.buttonClickedPayload;
|
||||
if (buttonClickedPayload) {
|
||||
const invokedFunction = rawObj.commonEventObject.invokedFunction;
|
||||
const actionParameters = recordParamsToActionParameters(rawObj.commonEventObject.parameters);
|
||||
eventPayload = {
|
||||
type: "CARD_CLICKED",
|
||||
space: buttonClickedPayload.space,
|
||||
message: buttonClickedPayload.message,
|
||||
user: buttonClickedPayload.user ?? chat.user,
|
||||
eventTime: chat.eventTime,
|
||||
action:
|
||||
buttonClickedPayload.action ??
|
||||
({
|
||||
...(typeof invokedFunction === "string" ? { actionMethodName: invokedFunction } : {}),
|
||||
...(actionParameters ? { parameters: actionParameters } : {}),
|
||||
} satisfies GoogleChatAction),
|
||||
commonEventObject: {
|
||||
...(typeof invokedFunction === "string" ? { invokedFunction } : {}),
|
||||
parameters: rawObj.commonEventObject.parameters,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const event = eventPayload as GoogleChatEvent;
|
||||
const eventType = event.type ?? (eventPayload as { eventType?: string }).eventType;
|
||||
if (typeof eventType !== "string") {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (!event.space || typeof event.space !== "object" || Array.isArray(event.space)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (eventType === "MESSAGE") {
|
||||
if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return { ok: false };
|
||||
}
|
||||
} else if (eventType === "CARD_CLICKED") {
|
||||
if (!event.user || typeof event.user !== "object" || Array.isArray(event.user)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, event, addOnBearerToken };
|
||||
}
|
||||
|
||||
type GoogleChatWebhookAuthRejection = {
|
||||
target: WebhookTarget;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
async function verifyGoogleChatTargetAuth(
|
||||
target: WebhookTarget,
|
||||
bearer: string,
|
||||
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||
const verification = await verifyGoogleChatRequest({
|
||||
bearer,
|
||||
audienceType: target.audienceType,
|
||||
audience: target.audience,
|
||||
expectedAddOnPrincipal: target.account.config.appPrincipal,
|
||||
});
|
||||
return verification.ok ? { ok: true } : { ok: false, reason: verification.reason ?? "unknown" };
|
||||
}
|
||||
|
||||
function logGoogleChatWebhookAuthRejections(rejections: GoogleChatWebhookAuthRejection[]): void {
|
||||
for (const rejection of rejections) {
|
||||
rejection.target.runtime.log?.(
|
||||
`[${rejection.target.account.accountId}] Google Chat webhook auth rejected: ${rejection.reason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function logGoogleChatWebhookAuthRejectedForTargets(
|
||||
targets: readonly WebhookTarget[],
|
||||
reason: string,
|
||||
): void {
|
||||
logGoogleChatWebhookAuthRejections(targets.map((target) => ({ target, reason })));
|
||||
}
|
||||
|
||||
async function resolveGoogleChatWebhookTargetWithAuthOrReject(params: {
|
||||
targets: readonly WebhookTarget[];
|
||||
res: ServerResponse;
|
||||
bearer: string;
|
||||
}): Promise<WebhookTarget | null> {
|
||||
const rejections: GoogleChatWebhookAuthRejection[] = [];
|
||||
let verifiedTargetCount = 0;
|
||||
const selectedTarget = await resolveWebhookTargetWithAuthOrReject({
|
||||
targets: params.targets,
|
||||
res: params.res,
|
||||
isMatch: async (target) => {
|
||||
const verification = await verifyGoogleChatTargetAuth(target, params.bearer);
|
||||
if (verification.ok) {
|
||||
verifiedTargetCount += 1;
|
||||
return true;
|
||||
}
|
||||
rejections.push({ target, reason: verification.reason });
|
||||
return false;
|
||||
},
|
||||
});
|
||||
if (!selectedTarget && verifiedTargetCount === 0) {
|
||||
logGoogleChatWebhookAuthRejections(rejections);
|
||||
}
|
||||
return selectedTarget;
|
||||
}
|
||||
|
||||
export function warnAppPrincipalMisconfiguration(params: {
|
||||
accountId: string;
|
||||
audienceType?: string;
|
||||
appPrincipal?: string | null;
|
||||
log?: (message: string) => void;
|
||||
}): void {
|
||||
if (params.audienceType !== "app-url") {
|
||||
return;
|
||||
}
|
||||
const principal = params.appPrincipal?.trim();
|
||||
if (!principal) {
|
||||
params.log?.(
|
||||
`[${params.accountId}] appPrincipal is missing for audienceType "app-url"; add-on token verification will fail. Set appPrincipal to the numeric OAuth 2.0 client ID (uniqueId, 21 digits), not an email.`,
|
||||
);
|
||||
} else if (principal.includes("@")) {
|
||||
params.log?.(
|
||||
`[${params.accountId}] appPrincipal "${principal}" looks like an email address. Set appPrincipal to the numeric OAuth 2.0 client ID (uniqueId, 21 digits), not an email.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createGoogleChatWebhookRequestHandler(params: {
|
||||
webhookTargets: Map<string, WebhookTarget[]>;
|
||||
webhookRateLimiter: FixedWindowRateLimiter;
|
||||
webhookInFlightLimiter: WebhookInFlightLimiter;
|
||||
processEvent: (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
|
||||
}): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
|
||||
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
|
||||
const path = normalizeWebhookPath(new URL(req.url ?? "/", "http://localhost").pathname);
|
||||
// Shared-path registrations use the same gateway proxy settings in normal runtime setup.
|
||||
const config = params.webhookTargets.get(path)?.[0]?.config;
|
||||
const clientIp =
|
||||
resolveRequestClientIp(
|
||||
req,
|
||||
config?.gateway?.trustedProxies,
|
||||
config?.gateway?.allowRealIpFallback === true,
|
||||
) ?? "unknown";
|
||||
|
||||
return await withResolvedWebhookRequestPipeline({
|
||||
req,
|
||||
res,
|
||||
targetsByPath: params.webhookTargets,
|
||||
allowMethods: ["POST"],
|
||||
requireJsonContentType: true,
|
||||
rateLimiter: params.webhookRateLimiter,
|
||||
rateLimitKey: `${path}:${clientIp}`,
|
||||
inFlightLimiter: params.webhookInFlightLimiter,
|
||||
handle: async ({ targets }) => {
|
||||
const headerBearer = extractBearerToken(req.headers.authorization);
|
||||
let selectedTarget: WebhookTarget | null;
|
||||
let parsedEvent: GoogleChatEvent | null;
|
||||
const readAndParseEvent = async (
|
||||
profile: "pre-auth" | "post-auth",
|
||||
): Promise<ParsedGoogleChatInboundSuccess | null> => {
|
||||
const body = await readJsonWebhookBodyOrReject({
|
||||
req,
|
||||
res,
|
||||
profile,
|
||||
...(profile === "pre-auth"
|
||||
? {
|
||||
maxBytes: ADD_ON_PREAUTH_MAX_BYTES,
|
||||
timeoutMs: ADD_ON_PREAUTH_TIMEOUT_MS,
|
||||
}
|
||||
: {}),
|
||||
emptyObjectOnEmpty: false,
|
||||
invalidJsonMessage: "invalid payload",
|
||||
});
|
||||
if (!body.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseGoogleChatInboundPayload(body.value, res);
|
||||
return parsed.ok ? parsed : null;
|
||||
};
|
||||
|
||||
if (headerBearer) {
|
||||
selectedTarget = await resolveGoogleChatWebhookTargetWithAuthOrReject({
|
||||
targets,
|
||||
res,
|
||||
bearer: headerBearer,
|
||||
});
|
||||
if (!selectedTarget) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsed = await readAndParseEvent("post-auth");
|
||||
if (!parsed) {
|
||||
return true;
|
||||
}
|
||||
parsedEvent = parsed.event;
|
||||
} else {
|
||||
const parsed = await readAndParseEvent("pre-auth");
|
||||
if (!parsed) {
|
||||
return true;
|
||||
}
|
||||
parsedEvent = parsed.event;
|
||||
|
||||
if (!parsed.addOnBearerToken) {
|
||||
logGoogleChatWebhookAuthRejectedForTargets(targets, "missing token");
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
selectedTarget = await resolveGoogleChatWebhookTargetWithAuthOrReject({
|
||||
targets,
|
||||
res,
|
||||
bearer: parsed.addOnBearerToken,
|
||||
});
|
||||
if (!selectedTarget) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedTarget || !parsedEvent) {
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
const dispatchTarget = selectedTarget;
|
||||
dispatchTarget.statusSink?.({ lastInboundAt: Date.now() });
|
||||
params.processEvent(parsedEvent, dispatchTarget).catch((err: unknown) => {
|
||||
dispatchTarget.runtime.error?.(
|
||||
`[${dispatchTarget.account.accountId}] Google Chat webhook failed: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end("{}");
|
||||
return true;
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
145
extensions/googlechat/src/monitor.reply-delivery.test.ts
Normal file
145
extensions/googlechat/src/monitor.reply-delivery.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
// Googlechat tests cover monitor.reply delivery plugin behavior.
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import type { GoogleChatCoreRuntime, GoogleChatRuntimeEnv } from "./monitor-types.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
deleteGoogleChatMessage: vi.fn(),
|
||||
sendGoogleChatMessage: vi.fn(),
|
||||
updateGoogleChatMessage: vi.fn(),
|
||||
uploadGoogleChatAttachment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
deleteGoogleChatMessage: mocks.deleteGoogleChatMessage,
|
||||
sendGoogleChatMessage: mocks.sendGoogleChatMessage,
|
||||
updateGoogleChatMessage: mocks.updateGoogleChatMessage,
|
||||
uploadGoogleChatAttachment: mocks.uploadGoogleChatAttachment,
|
||||
}));
|
||||
|
||||
const account = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "inline",
|
||||
config: {},
|
||||
} as ResolvedGoogleChatAccount;
|
||||
|
||||
const config = {} as OpenClawConfig;
|
||||
|
||||
function createCore(params?: {
|
||||
chunks?: readonly string[];
|
||||
media?: { buffer: Buffer; contentType?: string; fileName?: string };
|
||||
}) {
|
||||
return {
|
||||
channel: {
|
||||
text: {
|
||||
resolveChunkMode: vi.fn(() => "markdown"),
|
||||
chunkMarkdownTextWithMode: vi.fn((text: string) => params?.chunks ?? [text]),
|
||||
},
|
||||
media: {
|
||||
readRemoteMediaBuffer: vi.fn(async () => params?.media ?? { buffer: Buffer.from("image") }),
|
||||
},
|
||||
},
|
||||
} as unknown as GoogleChatCoreRuntime;
|
||||
}
|
||||
|
||||
function createRuntime() {
|
||||
return {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
} satisfies GoogleChatRuntimeEnv;
|
||||
}
|
||||
|
||||
let deliverGoogleChatReply: typeof import("./monitor-reply-delivery.js").deliverGoogleChatReply;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
({ deliverGoogleChatReply } = await import("./monitor-reply-delivery.js"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./api.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Google Chat reply delivery", () => {
|
||||
it("resends the first text chunk as a new message when typing update fails", async () => {
|
||||
const core = createCore({ chunks: ["first chunk", "second chunk"] });
|
||||
const runtime = createRuntime();
|
||||
const statusSink = vi.fn();
|
||||
mocks.updateGoogleChatMessage.mockRejectedValueOnce(new Error("message not found"));
|
||||
mocks.sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/fallback" });
|
||||
|
||||
await deliverGoogleChatReply({
|
||||
payload: { text: "first chunk\n\nsecond chunk", replyToId: "spaces/AAA/threads/root" },
|
||||
account,
|
||||
spaceId: "spaces/AAA",
|
||||
runtime,
|
||||
core,
|
||||
config,
|
||||
statusSink,
|
||||
typingMessageName: "spaces/AAA/messages/typing",
|
||||
});
|
||||
|
||||
expect(mocks.updateGoogleChatMessage).toHaveBeenCalledWith({
|
||||
account,
|
||||
messageName: "spaces/AAA/messages/typing",
|
||||
text: "first chunk",
|
||||
});
|
||||
expect(mocks.sendGoogleChatMessage).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.sendGoogleChatMessage).toHaveBeenNthCalledWith(1, {
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "first chunk",
|
||||
thread: "spaces/AAA/threads/root",
|
||||
});
|
||||
expect(mocks.sendGoogleChatMessage).toHaveBeenNthCalledWith(2, {
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "second chunk",
|
||||
thread: "spaces/AAA/threads/root",
|
||||
});
|
||||
expect(statusSink).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"Google Chat message send failed: Error: message not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not update a deleted typing message before sending media with a caption", async () => {
|
||||
const core = createCore({
|
||||
media: { buffer: Buffer.from("image"), contentType: "image/png", fileName: "reply.png" },
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
mocks.deleteGoogleChatMessage.mockResolvedValue(undefined);
|
||||
mocks.uploadGoogleChatAttachment.mockResolvedValue({ attachmentUploadToken: "upload-token" });
|
||||
mocks.sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/media" });
|
||||
|
||||
await deliverGoogleChatReply({
|
||||
payload: {
|
||||
text: "caption",
|
||||
mediaUrl: "https://example.invalid/reply.png",
|
||||
replyToId: "spaces/AAA/threads/root",
|
||||
},
|
||||
account,
|
||||
spaceId: "spaces/AAA",
|
||||
runtime,
|
||||
core,
|
||||
config,
|
||||
typingMessageName: "spaces/AAA/messages/typing",
|
||||
});
|
||||
|
||||
expect(mocks.deleteGoogleChatMessage).toHaveBeenCalledWith({
|
||||
account,
|
||||
messageName: "spaces/AAA/messages/typing",
|
||||
});
|
||||
expect(mocks.updateGoogleChatMessage).not.toHaveBeenCalled();
|
||||
expect(mocks.sendGoogleChatMessage).toHaveBeenCalledWith({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "caption",
|
||||
thread: "spaces/AAA/threads/root",
|
||||
attachments: [{ attachmentUploadToken: "upload-token", contentName: "reply.png" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
555
extensions/googlechat/src/monitor.test.ts
Normal file
555
extensions/googlechat/src/monitor.test.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
// Googlechat tests cover monitor plugin behavior.
|
||||
import { recordChannelBotPairLoopAndCheckSuppression } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import type { GoogleChatCoreRuntime, GoogleChatRuntimeEnv } from "./monitor-types.js";
|
||||
import { testing } from "./monitor.js";
|
||||
import type { GoogleChatEvent } from "./types.js";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
downloadGoogleChatMedia: vi.fn(),
|
||||
sendGoogleChatMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
const accessMocks = vi.hoisted(() => ({
|
||||
applyGoogleChatInboundAccessPolicy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
downloadGoogleChatMedia: apiMocks.downloadGoogleChatMedia,
|
||||
sendGoogleChatMessage: apiMocks.sendGoogleChatMessage,
|
||||
}));
|
||||
|
||||
vi.mock("./monitor-access.js", () => ({
|
||||
applyGoogleChatInboundAccessPolicy: accessMocks.applyGoogleChatInboundAccessPolicy,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
apiMocks.downloadGoogleChatMedia.mockReset();
|
||||
apiMocks.sendGoogleChatMessage.mockReset();
|
||||
accessMocks.applyGoogleChatInboundAccessPolicy.mockReset();
|
||||
});
|
||||
|
||||
function createInboundClassificationHarness() {
|
||||
const resolveAgentRoute = vi.fn(() => ({
|
||||
agentId: "agent-1",
|
||||
accountId: "work",
|
||||
sessionKey: "session-1",
|
||||
}));
|
||||
const buildContext = vi.fn((payload: unknown) => payload);
|
||||
const runTurn = vi.fn();
|
||||
const core = {
|
||||
logging: { shouldLogVerbose: () => false },
|
||||
channel: {
|
||||
routing: { resolveAgentRoute },
|
||||
session: {
|
||||
resolveStorePath: () => "/tmp/openclaw-googlechat-test",
|
||||
readSessionUpdatedAt: () => undefined,
|
||||
recordInboundSession: vi.fn(),
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: () => ({}),
|
||||
formatAgentEnvelope: ({ body }: { body: string }) => body,
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
|
||||
},
|
||||
inbound: { buildContext, run: runTurn },
|
||||
},
|
||||
} as unknown as GoogleChatCoreRuntime;
|
||||
return { buildContext, core, resolveAgentRoute, runTurn };
|
||||
}
|
||||
|
||||
describe("googlechat monitor bot loop protection", () => {
|
||||
it("maps accepted bot-authored messages to shared channel-turn facts", () => {
|
||||
expect(
|
||||
testing.resolveGoogleChatBotLoopProtection({
|
||||
allowBots: true,
|
||||
isBotSender: true,
|
||||
senderId: "users/other-bot",
|
||||
appUserId: "users/app-bot",
|
||||
accountId: "work",
|
||||
conversationId: "spaces/AAA",
|
||||
config: { maxEventsPerWindow: 3 },
|
||||
defaultsConfig: { maxEventsPerWindow: 20 },
|
||||
eventTime: "2026-03-22T00:00:00.000Z",
|
||||
}),
|
||||
).toEqual({
|
||||
scopeId: "work",
|
||||
conversationId: "spaces/AAA",
|
||||
senderId: "users/other-bot",
|
||||
receiverId: "users/app-bot",
|
||||
config: { maxEventsPerWindow: 3 },
|
||||
defaultsConfig: { maxEventsPerWindow: 20 },
|
||||
defaultEnabled: true,
|
||||
nowMs: Date.parse("2026-03-22T00:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not guard human messages or the app's own echo", () => {
|
||||
expect(
|
||||
testing.resolveGoogleChatBotLoopProtection({
|
||||
allowBots: true,
|
||||
isBotSender: false,
|
||||
senderId: "users/alice",
|
||||
appUserId: "users/app",
|
||||
accountId: "work",
|
||||
conversationId: "spaces/AAA",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
testing.resolveGoogleChatBotLoopProtection({
|
||||
allowBots: true,
|
||||
isBotSender: true,
|
||||
senderId: "users/app",
|
||||
appUserId: "users/app",
|
||||
accountId: "work",
|
||||
conversationId: "spaces/AAA",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("layers space bot loop overrides over account settings field-by-field", () => {
|
||||
expect(
|
||||
testing.resolveGoogleChatBotLoopProtectionConfig({
|
||||
accountConfig: { windowSeconds: 120, cooldownSeconds: 240 },
|
||||
groupConfig: { maxEventsPerWindow: 3 },
|
||||
}),
|
||||
).toEqual({
|
||||
maxEventsPerWindow: 3,
|
||||
windowSeconds: 120,
|
||||
cooldownSeconds: 240,
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses bot loops before creating typing messages", async () => {
|
||||
const eventTimeMs = Date.parse("2026-03-22T00:00:00.000Z");
|
||||
const accountId = `bot-loop-typing-${eventTimeMs}`;
|
||||
const conversationId = "spaces/LOOP";
|
||||
const senderId = "users/other-bot";
|
||||
const receiverId = "users/app";
|
||||
const runTurn = vi.fn();
|
||||
const core = {
|
||||
logging: { shouldLogVerbose: () => false },
|
||||
channel: {
|
||||
inbound: { run: runTurn },
|
||||
},
|
||||
} as unknown as GoogleChatCoreRuntime;
|
||||
const runtime = { error: vi.fn(), log: vi.fn() } satisfies GoogleChatRuntimeEnv;
|
||||
const account = {
|
||||
accountId,
|
||||
config: {
|
||||
allowBots: true,
|
||||
botUser: receiverId,
|
||||
botLoopProtection: { maxEventsPerWindow: 1, windowSeconds: 60, cooldownSeconds: 60 },
|
||||
typingIndicator: "message",
|
||||
},
|
||||
credentialSource: "inline",
|
||||
} as ResolvedGoogleChatAccount;
|
||||
const event = {
|
||||
type: "MESSAGE",
|
||||
eventTime: "2026-03-22T00:00:00.001Z",
|
||||
space: { name: conversationId, type: "DM" },
|
||||
message: {
|
||||
name: "spaces/LOOP/messages/2",
|
||||
text: "loop",
|
||||
sender: { name: senderId, type: "BOT" },
|
||||
},
|
||||
} satisfies GoogleChatEvent;
|
||||
|
||||
accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({
|
||||
ok: true,
|
||||
commandAuthorized: undefined,
|
||||
effectiveWasMentioned: undefined,
|
||||
groupBotLoopProtection: undefined,
|
||||
groupSystemPrompt: undefined,
|
||||
});
|
||||
recordChannelBotPairLoopAndCheckSuppression({
|
||||
scopeId: accountId,
|
||||
conversationId,
|
||||
senderId,
|
||||
receiverId,
|
||||
config: account.config.botLoopProtection,
|
||||
defaultEnabled: true,
|
||||
nowMs: eventTimeMs,
|
||||
});
|
||||
|
||||
await testing.processMessageWithPipeline({
|
||||
event,
|
||||
account,
|
||||
config: {},
|
||||
runtime,
|
||||
core,
|
||||
mediaMaxMb: 10,
|
||||
});
|
||||
|
||||
expect(apiMocks.sendGoogleChatMessage).not.toHaveBeenCalled();
|
||||
expect(apiMocks.downloadGoogleChatMedia).not.toHaveBeenCalled();
|
||||
expect(runTurn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechat monitor inbound space classification", () => {
|
||||
const cases = [
|
||||
{ name: "legacy DM", space: { type: "DM" }, peerKind: "direct" },
|
||||
{ name: "modern direct message", space: { spaceType: "DIRECT_MESSAGE" }, peerKind: "direct" },
|
||||
{ name: "single-user bot DM", space: { singleUserBotDm: true }, peerKind: "direct" },
|
||||
{ name: "modern space", space: { spaceType: "SPACE" }, peerKind: "group" },
|
||||
{ name: "modern group chat", space: { spaceType: "GROUP_CHAT" }, peerKind: "group" },
|
||||
{
|
||||
name: "modern space over legacy DM",
|
||||
space: { type: "DM", spaceType: "SPACE" },
|
||||
peerKind: "group",
|
||||
},
|
||||
] as const;
|
||||
|
||||
it.each(cases)("$name uses the expected access and route branch", async ({ space, peerKind }) => {
|
||||
const { buildContext, core, resolveAgentRoute, runTurn } = createInboundClassificationHarness();
|
||||
const account = {
|
||||
accountId: "work",
|
||||
config: {},
|
||||
credentialSource: "inline",
|
||||
} as ResolvedGoogleChatAccount;
|
||||
const event = {
|
||||
type: "MESSAGE",
|
||||
space: { name: "spaces/CLASSIFY", ...space },
|
||||
message: {
|
||||
name: "spaces/CLASSIFY/messages/1",
|
||||
text: "hello",
|
||||
sender: { name: "users/alice", displayName: "Alice", type: "HUMAN" },
|
||||
},
|
||||
} satisfies GoogleChatEvent;
|
||||
|
||||
accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({
|
||||
ok: true,
|
||||
commandAuthorized: undefined,
|
||||
effectiveWasMentioned: undefined,
|
||||
groupBotLoopProtection: undefined,
|
||||
groupSystemPrompt: undefined,
|
||||
});
|
||||
|
||||
await testing.processMessageWithPipeline({
|
||||
event,
|
||||
account,
|
||||
config: {},
|
||||
runtime: { error: vi.fn(), log: vi.fn() },
|
||||
core,
|
||||
mediaMaxMb: 10,
|
||||
});
|
||||
|
||||
const isGroup = peerKind === "group";
|
||||
expect(accessMocks.applyGoogleChatInboundAccessPolicy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isGroup }),
|
||||
);
|
||||
expect(resolveAgentRoute).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
channel: "googlechat",
|
||||
accountId: "work",
|
||||
peer: { kind: peerKind, id: "spaces/CLASSIFY" },
|
||||
});
|
||||
expect(buildContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conversation: expect.objectContaining({ kind: isGroup ? "channel" : "direct" }),
|
||||
extra: expect.objectContaining({ ChatType: isGroup ? "channel" : "direct" }),
|
||||
}),
|
||||
);
|
||||
expect(runTurn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechat monitor sender bot status", () => {
|
||||
function botStatusEvent(senderType: "BOT" | "HUMAN", messageId: string): GoogleChatEvent {
|
||||
return {
|
||||
type: "MESSAGE",
|
||||
space: { name: "spaces/DM", type: "DM" },
|
||||
message: {
|
||||
name: `spaces/DM/messages/${messageId}`,
|
||||
text: "hello",
|
||||
sender: { name: "users/sender", displayName: "Sender", type: senderType },
|
||||
},
|
||||
} satisfies GoogleChatEvent;
|
||||
}
|
||||
|
||||
it("forwards bot sender status to the inbound context when allowBots is true", async () => {
|
||||
const { buildContext, core } = createInboundClassificationHarness();
|
||||
accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({
|
||||
ok: true,
|
||||
commandAuthorized: undefined,
|
||||
effectiveWasMentioned: undefined,
|
||||
groupBotLoopProtection: undefined,
|
||||
groupSystemPrompt: undefined,
|
||||
});
|
||||
|
||||
await testing.processMessageWithPipeline({
|
||||
event: botStatusEvent("BOT", "1"),
|
||||
account: {
|
||||
accountId: "work",
|
||||
config: { allowBots: true },
|
||||
credentialSource: "inline",
|
||||
} as ResolvedGoogleChatAccount,
|
||||
config: {},
|
||||
runtime: { error: vi.fn(), log: vi.fn() },
|
||||
core,
|
||||
mediaMaxMb: 10,
|
||||
});
|
||||
|
||||
expect(buildContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sender: expect.objectContaining({ isBot: true }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits bot sender status for human senders", async () => {
|
||||
const { buildContext, core } = createInboundClassificationHarness();
|
||||
accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({
|
||||
ok: true,
|
||||
commandAuthorized: undefined,
|
||||
effectiveWasMentioned: undefined,
|
||||
groupBotLoopProtection: undefined,
|
||||
groupSystemPrompt: undefined,
|
||||
});
|
||||
|
||||
await testing.processMessageWithPipeline({
|
||||
event: botStatusEvent("HUMAN", "2"),
|
||||
account: {
|
||||
accountId: "work",
|
||||
config: {},
|
||||
credentialSource: "inline",
|
||||
} as ResolvedGoogleChatAccount,
|
||||
config: {},
|
||||
runtime: { error: vi.fn(), log: vi.fn() },
|
||||
core,
|
||||
mediaMaxMb: 10,
|
||||
});
|
||||
|
||||
expect(buildContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sender: expect.objectContaining({ isBot: undefined }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechat monitor direct messages", () => {
|
||||
it("creates typing messages by default", async () => {
|
||||
const runTurn = vi.fn();
|
||||
const buildContext = vi.fn((payload: unknown) => payload);
|
||||
const core = {
|
||||
logging: { shouldLogVerbose: () => false },
|
||||
channel: {
|
||||
routing: {
|
||||
resolveAgentRoute: () => ({
|
||||
agentId: "agent-1",
|
||||
accountId: "work",
|
||||
sessionKey: "session-1",
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: () => "/tmp/openclaw-googlechat-test",
|
||||
readSessionUpdatedAt: () => undefined,
|
||||
recordInboundSession: vi.fn(),
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: () => ({}),
|
||||
formatAgentEnvelope: ({ body }: { body: string }) => body,
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
|
||||
},
|
||||
inbound: { buildContext, run: runTurn },
|
||||
},
|
||||
} as unknown as GoogleChatCoreRuntime;
|
||||
const runtime = { error: vi.fn(), log: vi.fn() } satisfies GoogleChatRuntimeEnv;
|
||||
const account = {
|
||||
accountId: "work",
|
||||
config: {},
|
||||
credentialSource: "inline",
|
||||
} as ResolvedGoogleChatAccount;
|
||||
const event = {
|
||||
type: "MESSAGE",
|
||||
eventTime: "2026-03-22T00:00:00.001Z",
|
||||
space: { name: "spaces/DM", type: "DM" },
|
||||
message: {
|
||||
name: "spaces/DM/messages/2",
|
||||
text: "hello",
|
||||
sender: { name: "users/alice", displayName: "Alice", type: "HUMAN" },
|
||||
},
|
||||
} satisfies GoogleChatEvent;
|
||||
|
||||
accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({
|
||||
ok: true,
|
||||
commandAuthorized: undefined,
|
||||
effectiveWasMentioned: undefined,
|
||||
groupBotLoopProtection: undefined,
|
||||
groupSystemPrompt: undefined,
|
||||
});
|
||||
|
||||
await testing.processMessageWithPipeline({
|
||||
event,
|
||||
account,
|
||||
config: {},
|
||||
runtime,
|
||||
core,
|
||||
mediaMaxMb: 10,
|
||||
});
|
||||
|
||||
expect(apiMocks.sendGoogleChatMessage).toHaveBeenCalledWith({
|
||||
account,
|
||||
space: "spaces/DM",
|
||||
text: "_OpenClaw is typing..._",
|
||||
thread: undefined,
|
||||
});
|
||||
expect(runTurn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("omits thread metadata from DM reply context and typing messages", async () => {
|
||||
const runTurn = vi.fn();
|
||||
const buildContext = vi.fn((payload: unknown) => payload);
|
||||
const core = {
|
||||
logging: { shouldLogVerbose: () => false },
|
||||
channel: {
|
||||
routing: {
|
||||
resolveAgentRoute: () => ({
|
||||
agentId: "agent-1",
|
||||
accountId: "work",
|
||||
sessionKey: "session-1",
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: () => "/tmp/openclaw-googlechat-test",
|
||||
readSessionUpdatedAt: () => undefined,
|
||||
recordInboundSession: vi.fn(),
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: () => ({}),
|
||||
formatAgentEnvelope: ({ body }: { body: string }) => body,
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
|
||||
},
|
||||
inbound: { buildContext, run: runTurn },
|
||||
},
|
||||
} as unknown as GoogleChatCoreRuntime;
|
||||
const runtime = { error: vi.fn(), log: vi.fn() } satisfies GoogleChatRuntimeEnv;
|
||||
const account = {
|
||||
accountId: "work",
|
||||
config: {
|
||||
typingIndicator: "message",
|
||||
},
|
||||
credentialSource: "inline",
|
||||
} as ResolvedGoogleChatAccount;
|
||||
const event = {
|
||||
type: "MESSAGE",
|
||||
eventTime: "2026-03-22T00:00:00.001Z",
|
||||
space: { name: "spaces/DM", type: "DM" },
|
||||
message: {
|
||||
name: "spaces/DM/messages/2",
|
||||
text: "hello",
|
||||
thread: { name: "spaces/DM/threads/thread-1" },
|
||||
sender: { name: "users/alice", displayName: "Alice", type: "HUMAN" },
|
||||
},
|
||||
} satisfies GoogleChatEvent;
|
||||
|
||||
accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({
|
||||
ok: true,
|
||||
commandAuthorized: undefined,
|
||||
effectiveWasMentioned: undefined,
|
||||
groupBotLoopProtection: undefined,
|
||||
groupSystemPrompt: undefined,
|
||||
});
|
||||
apiMocks.sendGoogleChatMessage.mockResolvedValue({
|
||||
messageName: "spaces/DM/messages/typing",
|
||||
});
|
||||
|
||||
await testing.processMessageWithPipeline({
|
||||
event,
|
||||
account,
|
||||
config: {},
|
||||
runtime,
|
||||
core,
|
||||
mediaMaxMb: 10,
|
||||
});
|
||||
|
||||
expect(buildContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reply: {
|
||||
to: "googlechat:spaces/DM",
|
||||
originatingTo: "googlechat:spaces/DM",
|
||||
replyToId: undefined,
|
||||
replyToIdFull: undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(apiMocks.sendGoogleChatMessage).toHaveBeenCalledWith({
|
||||
account,
|
||||
space: "spaces/DM",
|
||||
text: "_OpenClaw is typing..._",
|
||||
thread: undefined,
|
||||
});
|
||||
expect(runTurn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("drops invalid event timestamps from inbound runtime payloads", async () => {
|
||||
const runTurn = vi.fn();
|
||||
const buildContext = vi.fn((payload: unknown) => payload);
|
||||
const formatAgentEnvelope = vi.fn(({ body }: { body: string }) => body);
|
||||
const core = {
|
||||
logging: { shouldLogVerbose: () => false },
|
||||
channel: {
|
||||
routing: {
|
||||
resolveAgentRoute: () => ({
|
||||
agentId: "agent-1",
|
||||
accountId: "work",
|
||||
sessionKey: "session-1",
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: () => "/tmp/openclaw-googlechat-test",
|
||||
readSessionUpdatedAt: () => undefined,
|
||||
recordInboundSession: vi.fn(),
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: () => ({}),
|
||||
formatAgentEnvelope,
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
|
||||
},
|
||||
inbound: { buildContext, run: runTurn },
|
||||
},
|
||||
} as unknown as GoogleChatCoreRuntime;
|
||||
const runtime = { error: vi.fn(), log: vi.fn() } satisfies GoogleChatRuntimeEnv;
|
||||
const account = {
|
||||
accountId: "work",
|
||||
config: {
|
||||
typingIndicator: "message",
|
||||
},
|
||||
credentialSource: "inline",
|
||||
} as ResolvedGoogleChatAccount;
|
||||
const event = {
|
||||
type: "MESSAGE",
|
||||
eventTime: "not-a-timestamp",
|
||||
space: { name: "spaces/DM", type: "DM" },
|
||||
message: {
|
||||
name: "spaces/DM/messages/2",
|
||||
text: "hello",
|
||||
sender: { name: "users/alice", displayName: "Alice", type: "HUMAN" },
|
||||
},
|
||||
} satisfies GoogleChatEvent;
|
||||
|
||||
accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({
|
||||
ok: true,
|
||||
commandAuthorized: undefined,
|
||||
effectiveWasMentioned: undefined,
|
||||
groupBotLoopProtection: undefined,
|
||||
groupSystemPrompt: undefined,
|
||||
});
|
||||
|
||||
await testing.processMessageWithPipeline({
|
||||
event,
|
||||
account,
|
||||
config: {},
|
||||
runtime,
|
||||
core,
|
||||
mediaMaxMb: 10,
|
||||
});
|
||||
|
||||
expect(formatAgentEnvelope).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timestamp: undefined }),
|
||||
);
|
||||
expect(buildContext).toHaveBeenCalledWith(expect.objectContaining({ timestamp: undefined }));
|
||||
const runArg = runTurn.mock.calls[0]?.[0] as
|
||||
| { adapter?: { ingest?: () => { timestamp?: number } } }
|
||||
| undefined;
|
||||
expect(runArg?.adapter?.ingest?.().timestamp).toBeUndefined();
|
||||
});
|
||||
});
|
||||
544
extensions/googlechat/src/monitor.ts
Normal file
544
extensions/googlechat/src/monitor.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
// Googlechat plugin module implements monitor behavior.
|
||||
import {
|
||||
recordChannelBotPairLoopAndCheckSuppression,
|
||||
type ChannelBotLoopProtectionFacts,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
||||
resolveWebhookPath,
|
||||
} from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js";
|
||||
import { maybeHandleGoogleChatApprovalCardClick } from "./approval-card-click.js";
|
||||
import type { GoogleChatAudienceType } from "./auth.js";
|
||||
import { applyGoogleChatInboundAccessPolicy } from "./monitor-access.js";
|
||||
import { resolveGoogleChatDurableReplyOptions } from "./monitor-durable.js";
|
||||
import { deliverGoogleChatReply } from "./monitor-reply-delivery.js";
|
||||
import {
|
||||
registerGoogleChatWebhookTarget,
|
||||
setGoogleChatWebhookEventProcessor,
|
||||
} from "./monitor-routing.js";
|
||||
import type {
|
||||
GoogleChatCoreRuntime,
|
||||
GoogleChatMonitorOptions,
|
||||
GoogleChatRuntimeEnv,
|
||||
WebhookTarget,
|
||||
} from "./monitor-types.js";
|
||||
import { warnAppPrincipalMisconfiguration } from "./monitor-webhook.js";
|
||||
import { getGoogleChatRuntime } from "./runtime.js";
|
||||
import type { GoogleChatAttachment, GoogleChatEvent, GoogleChatSpace } from "./types.js";
|
||||
|
||||
setGoogleChatWebhookEventProcessor(processGoogleChatEvent);
|
||||
|
||||
function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
runtime.log?.(`[googlechat] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
|
||||
const normalized = normalizeOptionalLowercaseString(value);
|
||||
if (normalized === "app-url" || normalized === "app_url" || normalized === "app") {
|
||||
return "app-url";
|
||||
}
|
||||
if (
|
||||
normalized === "project-number" ||
|
||||
normalized === "project_number" ||
|
||||
normalized === "project"
|
||||
) {
|
||||
return "project-number";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveGoogleChatTimestampMs(eventTime?: string): number | undefined {
|
||||
if (!eventTime) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Date.parse(eventTime);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function isGoogleChatGroupSpace(space: GoogleChatSpace): boolean {
|
||||
const spaceType = (space.spaceType ?? "").toUpperCase();
|
||||
// Google Chat deprecates `type` in favor of `spaceType`; known modern
|
||||
// values must win if the fields disagree. Fall back to the bot-DM flag and
|
||||
// legacy type so incomplete payloads retain their existing direct routing.
|
||||
if (spaceType === "DIRECT_MESSAGE") {
|
||||
return false;
|
||||
}
|
||||
if (spaceType === "SPACE" || spaceType === "GROUP_CHAT") {
|
||||
return true;
|
||||
}
|
||||
return space.singleUserBotDm !== true && (space.type ?? "").toUpperCase() !== "DM";
|
||||
}
|
||||
|
||||
function resolveGoogleChatBotLoopProtection(params: {
|
||||
allowBots: boolean;
|
||||
isBotSender: boolean;
|
||||
senderId: string;
|
||||
appUserId: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
config?: ChannelBotLoopProtectionFacts["config"];
|
||||
defaultsConfig?: ChannelBotLoopProtectionFacts["defaultsConfig"];
|
||||
eventTime?: string;
|
||||
}): ChannelBotLoopProtectionFacts | undefined {
|
||||
if (
|
||||
!params.allowBots ||
|
||||
!params.isBotSender ||
|
||||
!params.senderId ||
|
||||
params.senderId === params.appUserId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
scopeId: params.accountId,
|
||||
conversationId: params.conversationId,
|
||||
senderId: params.senderId,
|
||||
receiverId: params.appUserId,
|
||||
config: params.config,
|
||||
defaultsConfig: params.defaultsConfig,
|
||||
defaultEnabled: true,
|
||||
nowMs: resolveGoogleChatTimestampMs(params.eventTime),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGoogleChatBotLoopProtectionConfig(params: {
|
||||
accountConfig?: ChannelBotLoopProtectionFacts["config"];
|
||||
groupConfig?: ChannelBotLoopProtectionFacts["config"];
|
||||
}): ChannelBotLoopProtectionFacts["config"] {
|
||||
return mergePairLoopGuardConfig(params.accountConfig, params.groupConfig);
|
||||
}
|
||||
|
||||
function shouldSuppressGoogleChatBotLoop(params: {
|
||||
botLoopProtection?: ChannelBotLoopProtectionFacts;
|
||||
core: GoogleChatCoreRuntime;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
}): boolean {
|
||||
if (!params.botLoopProtection) {
|
||||
return false;
|
||||
}
|
||||
const botLoopResult = recordChannelBotPairLoopAndCheckSuppression(params.botLoopProtection);
|
||||
if (!botLoopResult.suppressed) {
|
||||
return false;
|
||||
}
|
||||
logVerbose(
|
||||
params.core,
|
||||
params.runtime,
|
||||
`skip bot-to-bot loop in ${params.botLoopProtection.conversationId}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) {
|
||||
const eventType = event.type ?? (event as { eventType?: string }).eventType;
|
||||
if (eventType === "CARD_CLICKED") {
|
||||
await maybeHandleGoogleChatApprovalCardClick({ event, target });
|
||||
return;
|
||||
}
|
||||
if (eventType !== "MESSAGE") {
|
||||
return;
|
||||
}
|
||||
if (!event.message || !event.space) {
|
||||
return;
|
||||
}
|
||||
|
||||
await processMessageWithPipeline({
|
||||
event,
|
||||
account: target.account,
|
||||
config: target.config,
|
||||
runtime: target.runtime,
|
||||
core: target.core,
|
||||
statusSink: target.statusSink,
|
||||
mediaMaxMb: target.mediaMaxMb,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve bot display name with fallback chain:
|
||||
* 1. Account config name
|
||||
* 2. Agent name from config
|
||||
* 3. "OpenClaw" as generic fallback
|
||||
*/
|
||||
function resolveBotDisplayName(params: {
|
||||
accountName?: string;
|
||||
agentId: string;
|
||||
config: OpenClawConfig;
|
||||
}): string {
|
||||
const { accountName, agentId, config } = params;
|
||||
if (accountName?.trim()) {
|
||||
return accountName.trim();
|
||||
}
|
||||
const agent = config.agents?.list?.find((a) => a.id === agentId);
|
||||
if (agent?.name?.trim()) {
|
||||
return agent.name.trim();
|
||||
}
|
||||
return "OpenClaw";
|
||||
}
|
||||
|
||||
async function processMessageWithPipeline(params: {
|
||||
event: GoogleChatEvent;
|
||||
account: ResolvedGoogleChatAccount;
|
||||
config: OpenClawConfig;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
core: GoogleChatCoreRuntime;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
mediaMaxMb: number;
|
||||
}): Promise<void> {
|
||||
const { event, account, config, runtime, core, statusSink, mediaMaxMb } = params;
|
||||
const space = event.space;
|
||||
const message = event.message;
|
||||
if (!space || !message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spaceId = space.name ?? "";
|
||||
if (!spaceId) {
|
||||
return;
|
||||
}
|
||||
const isGroup = isGoogleChatGroupSpace(space);
|
||||
const sender = message.sender ?? event.user;
|
||||
const senderId = sender?.name ?? "";
|
||||
const senderName = sender?.displayName ?? "";
|
||||
const senderEmail = sender?.email ?? undefined;
|
||||
const isBotSender = sender?.type?.toUpperCase() === "BOT";
|
||||
const appUserId = account.config.botUser?.trim() || "users/app";
|
||||
|
||||
const allowBots = account.config.allowBots === true;
|
||||
if (!allowBots) {
|
||||
if (isBotSender) {
|
||||
logVerbose(core, runtime, `skip bot-authored message (${senderId || "unknown"})`);
|
||||
return;
|
||||
}
|
||||
if (senderId === "users/app") {
|
||||
logVerbose(core, runtime, "skip app-authored message");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const messageText = (message.argumentText ?? message.text ?? "").trim();
|
||||
const attachments = message.attachment ?? [];
|
||||
const hasMedia = attachments.length > 0;
|
||||
const rawBody = messageText || (hasMedia ? "<media:attachment>" : "");
|
||||
if (!rawBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
const access = await applyGoogleChatInboundAccessPolicy({
|
||||
account,
|
||||
config,
|
||||
core,
|
||||
space,
|
||||
message,
|
||||
isGroup,
|
||||
senderId,
|
||||
senderName,
|
||||
senderEmail,
|
||||
rawBody,
|
||||
statusSink,
|
||||
logVerbose: (messageLocal) => logVerbose(core, runtime, messageLocal),
|
||||
});
|
||||
if (!access.ok) {
|
||||
return;
|
||||
}
|
||||
const { commandAuthorized, effectiveWasMentioned, groupBotLoopProtection, groupSystemPrompt } =
|
||||
access;
|
||||
const botLoopProtection = resolveGoogleChatBotLoopProtection({
|
||||
allowBots,
|
||||
isBotSender,
|
||||
senderId,
|
||||
appUserId,
|
||||
accountId: account.accountId,
|
||||
conversationId: spaceId,
|
||||
config: resolveGoogleChatBotLoopProtectionConfig({
|
||||
accountConfig: account.config.botLoopProtection,
|
||||
groupConfig: groupBotLoopProtection,
|
||||
}),
|
||||
defaultsConfig: config.channels?.defaults?.botLoopProtection,
|
||||
eventTime: event.eventTime,
|
||||
});
|
||||
if (shouldSuppressGoogleChatBotLoop({ botLoopProtection, core, runtime })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
||||
cfg: config,
|
||||
channel: "googlechat",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? ("group" as const) : ("direct" as const),
|
||||
id: spaceId,
|
||||
},
|
||||
runtime: core.channel,
|
||||
sessionStore: config.session?.store,
|
||||
});
|
||||
|
||||
let mediaPath: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
if (attachments.length > 0) {
|
||||
const first = attachments[0];
|
||||
const attachmentData = await downloadAttachment(first, account, mediaMaxMb, core);
|
||||
if (attachmentData) {
|
||||
mediaPath = attachmentData.path;
|
||||
mediaType = attachmentData.contentType;
|
||||
}
|
||||
}
|
||||
|
||||
const fromLabel = isGroup
|
||||
? space.displayName || `space:${spaceId}`
|
||||
: senderName || `user:${senderId}`;
|
||||
const timestampMs = resolveGoogleChatTimestampMs(event.eventTime);
|
||||
const { storePath, body } = buildEnvelope({
|
||||
channel: "Google Chat",
|
||||
from: fromLabel,
|
||||
timestamp: timestampMs,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const replyThreadName = isGroup ? message.thread?.name : undefined;
|
||||
const ctxPayload = core.channel.inbound.buildContext({
|
||||
channel: "googlechat",
|
||||
accountId: route.accountId,
|
||||
messageId: message.name,
|
||||
messageIdFull: message.name,
|
||||
timestamp: timestampMs,
|
||||
from: `googlechat:${senderId}`,
|
||||
sender: {
|
||||
id: senderId,
|
||||
name: senderName || undefined,
|
||||
username: senderEmail,
|
||||
isBot: isBotSender || undefined,
|
||||
},
|
||||
conversation: {
|
||||
kind: isGroup ? "channel" : "direct",
|
||||
id: spaceId,
|
||||
label: fromLabel,
|
||||
},
|
||||
route: {
|
||||
agentId: route.agentId,
|
||||
accountId: route.accountId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
},
|
||||
reply: {
|
||||
to: `googlechat:${spaceId}`,
|
||||
originatingTo: `googlechat:${spaceId}`,
|
||||
replyToId: replyThreadName,
|
||||
replyToIdFull: replyThreadName,
|
||||
},
|
||||
message: {
|
||||
body,
|
||||
bodyForAgent: rawBody,
|
||||
rawBody,
|
||||
commandBody: rawBody,
|
||||
},
|
||||
media:
|
||||
mediaPath || mediaType
|
||||
? [
|
||||
{
|
||||
path: mediaPath,
|
||||
url: mediaPath,
|
||||
contentType: mediaType,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
supplemental: {
|
||||
groupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
|
||||
},
|
||||
extra: {
|
||||
ChatType: isGroup ? "channel" : "direct",
|
||||
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
GroupSubject: undefined,
|
||||
GroupSpace: isGroup ? (space.displayName ?? undefined) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Typing indicator setup
|
||||
// Note: Reaction mode requires user OAuth, not available with service account auth.
|
||||
// If reaction is configured, we fall back to message mode with a warning.
|
||||
let typingIndicator = account.config.typingIndicator ?? "message";
|
||||
if (typingIndicator === "reaction") {
|
||||
runtime.error?.(
|
||||
`[${account.accountId}] typingIndicator="reaction" requires user OAuth (not supported with service account). Falling back to "message" mode.`,
|
||||
);
|
||||
typingIndicator = "message";
|
||||
}
|
||||
let typingMessageName: string | undefined;
|
||||
|
||||
// Start typing indicator (message mode only, reaction mode not supported with app auth)
|
||||
if (typingIndicator === "message") {
|
||||
try {
|
||||
const botName = resolveBotDisplayName({
|
||||
accountName: account.config.name,
|
||||
agentId: route.agentId,
|
||||
config,
|
||||
});
|
||||
const result = await sendGoogleChatMessage({
|
||||
account,
|
||||
space: spaceId,
|
||||
text: `_${botName} is typing..._`,
|
||||
thread: replyThreadName,
|
||||
});
|
||||
typingMessageName = result?.messageName;
|
||||
} catch (err) {
|
||||
runtime.error?.(`Failed sending typing message: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
await core.channel.inbound.run({
|
||||
channel: "googlechat",
|
||||
accountId: route.accountId,
|
||||
raw: message,
|
||||
adapter: {
|
||||
ingest: () => ({
|
||||
id: message.name ?? spaceId,
|
||||
timestamp: timestampMs,
|
||||
rawText: rawBody,
|
||||
textForAgent: rawBody,
|
||||
textForCommands: rawBody,
|
||||
raw: message,
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
cfg: config,
|
||||
channel: "googlechat",
|
||||
accountId: route.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
durable: (payload, info) =>
|
||||
resolveGoogleChatDurableReplyOptions({
|
||||
payload,
|
||||
infoKind: info.kind,
|
||||
spaceId,
|
||||
typingMessageName,
|
||||
}),
|
||||
deliver: async (payload) => {
|
||||
await deliverGoogleChatReply({
|
||||
payload,
|
||||
account,
|
||||
spaceId,
|
||||
runtime,
|
||||
core,
|
||||
config,
|
||||
statusSink,
|
||||
typingMessageName,
|
||||
});
|
||||
// Only use typing message for first delivery
|
||||
typingMessageName = undefined;
|
||||
},
|
||||
onDelivered: () => {
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(
|
||||
`[${account.accountId}] Google Chat ${info.kind} reply failed: ${String(err)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
replyPipeline: {},
|
||||
record: {
|
||||
onRecordError: (err) => {
|
||||
runtime.error?.(`googlechat: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
processMessageWithPipeline,
|
||||
resolveGoogleChatBotLoopProtection,
|
||||
resolveGoogleChatBotLoopProtectionConfig,
|
||||
shouldSuppressGoogleChatBotLoop,
|
||||
};
|
||||
|
||||
async function downloadAttachment(
|
||||
attachment: GoogleChatAttachment,
|
||||
account: ResolvedGoogleChatAccount,
|
||||
mediaMaxMb: number,
|
||||
core: GoogleChatCoreRuntime,
|
||||
): Promise<{ path: string; contentType?: string } | null> {
|
||||
const resourceName = attachment.attachmentDataRef?.resourceName;
|
||||
if (!resourceName) {
|
||||
return null;
|
||||
}
|
||||
const maxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
||||
const downloaded = await downloadGoogleChatMedia({ account, resourceName, maxBytes });
|
||||
const saved = await core.channel.media.saveMediaBuffer(
|
||||
downloaded.buffer,
|
||||
downloaded.contentType ?? attachment.contentType,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
attachment.contentName,
|
||||
);
|
||||
return { path: saved.path, contentType: saved.contentType };
|
||||
}
|
||||
|
||||
function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): () => void {
|
||||
const core = getGoogleChatRuntime();
|
||||
const webhookPath = resolveWebhookPath({
|
||||
webhookPath: options.webhookPath,
|
||||
webhookUrl: options.webhookUrl,
|
||||
defaultPath: "/googlechat",
|
||||
});
|
||||
if (!webhookPath) {
|
||||
options.runtime.error?.(`[${options.account.accountId}] invalid webhook path`);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const audienceType = normalizeAudienceType(options.account.config.audienceType);
|
||||
const audience = options.account.config.audience?.trim();
|
||||
const mediaMaxMb = options.account.config.mediaMaxMb ?? 20;
|
||||
|
||||
warnAppPrincipalMisconfiguration({
|
||||
accountId: options.account.accountId,
|
||||
audienceType,
|
||||
appPrincipal: options.account.config.appPrincipal,
|
||||
log: options.runtime.log,
|
||||
});
|
||||
|
||||
const unregisterTarget = registerGoogleChatWebhookTarget({
|
||||
account: options.account,
|
||||
config: options.config,
|
||||
runtime: options.runtime,
|
||||
core,
|
||||
path: webhookPath,
|
||||
audienceType,
|
||||
audience,
|
||||
statusSink: options.statusSink,
|
||||
mediaMaxMb,
|
||||
});
|
||||
|
||||
return () => {
|
||||
unregisterTarget();
|
||||
};
|
||||
}
|
||||
|
||||
export async function startGoogleChatMonitor(
|
||||
params: GoogleChatMonitorOptions,
|
||||
): Promise<() => void> {
|
||||
return monitorGoogleChatProvider(params);
|
||||
}
|
||||
|
||||
export function resolveGoogleChatWebhookPath(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
}): string {
|
||||
return (
|
||||
resolveWebhookPath({
|
||||
webhookPath: params.account.config.webhookPath,
|
||||
webhookUrl: params.account.config.webhookUrl,
|
||||
defaultPath: "/googlechat",
|
||||
}) ?? "/googlechat"
|
||||
);
|
||||
}
|
||||
export { testing as __testing };
|
||||
258
extensions/googlechat/src/monitor.webhook-routing.test.ts
Normal file
258
extensions/googlechat/src/monitor.webhook-routing.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
// Googlechat tests cover monitor.webhook routing plugin behavior.
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import {
|
||||
createEmptyPluginRegistry,
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { createMockServerResponse } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { verifyGoogleChatRequest } from "./auth.js";
|
||||
import {
|
||||
handleGoogleChatWebhookRequest,
|
||||
registerGoogleChatWebhookTarget,
|
||||
} from "./monitor-routing.js";
|
||||
|
||||
vi.mock("./auth.js", () => ({
|
||||
verifyGoogleChatRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
function createWebhookRequest(params: {
|
||||
authorization?: string;
|
||||
payload: unknown;
|
||||
path?: string;
|
||||
}): IncomingMessage {
|
||||
const req = new EventEmitter() as IncomingMessage & {
|
||||
destroyed?: boolean;
|
||||
destroy: (error?: Error) => IncomingMessage;
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => IncomingMessage;
|
||||
};
|
||||
req.method = "POST";
|
||||
req.url = params.path ?? "/googlechat";
|
||||
req.headers = {
|
||||
authorization: params.authorization ?? "",
|
||||
"content-type": "application/json",
|
||||
};
|
||||
req.destroyed = false;
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
req.destroy = () => {
|
||||
req.destroyed = true;
|
||||
return req;
|
||||
};
|
||||
|
||||
const originalOn = req.on.bind(req);
|
||||
let bodyScheduled = false;
|
||||
req.on = ((event: string, listener: (...args: unknown[]) => void) => {
|
||||
const result = originalOn(event, listener);
|
||||
if (!bodyScheduled && event === "data") {
|
||||
bodyScheduled = true;
|
||||
void Promise.resolve().then(() => {
|
||||
req.emit("data", Buffer.from(JSON.stringify(params.payload), "utf-8"));
|
||||
if (!req.destroyed) {
|
||||
req.emit("end");
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}) as IncomingMessage["on"];
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
function createHeaderOnlyWebhookRequest(params: {
|
||||
authorization?: string;
|
||||
path?: string;
|
||||
}): IncomingMessage {
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = "POST";
|
||||
req.url = params.path ?? "/googlechat";
|
||||
req.headers = {
|
||||
authorization: params.authorization ?? "",
|
||||
"content-type": "application/json",
|
||||
};
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
return req;
|
||||
}
|
||||
|
||||
const baseAccount = (accountId: string) =>
|
||||
({
|
||||
accountId,
|
||||
enabled: true,
|
||||
credentialSource: "none",
|
||||
config: {},
|
||||
}) as ResolvedGoogleChatAccount;
|
||||
|
||||
function registerTwoTargets() {
|
||||
const sinkA = vi.fn();
|
||||
const sinkB = vi.fn();
|
||||
const logA = vi.fn();
|
||||
const logB = vi.fn();
|
||||
const core = {} as PluginRuntime;
|
||||
const config = {} as OpenClawConfig;
|
||||
|
||||
const unregisterA = registerGoogleChatWebhookTarget({
|
||||
account: baseAccount("A"),
|
||||
config,
|
||||
runtime: { log: logA },
|
||||
core,
|
||||
path: "/googlechat",
|
||||
statusSink: sinkA,
|
||||
mediaMaxMb: 5,
|
||||
});
|
||||
const unregisterB = registerGoogleChatWebhookTarget({
|
||||
account: baseAccount("B"),
|
||||
config,
|
||||
runtime: { log: logB },
|
||||
core,
|
||||
path: "/googlechat",
|
||||
statusSink: sinkB,
|
||||
mediaMaxMb: 5,
|
||||
});
|
||||
|
||||
return {
|
||||
logA,
|
||||
logB,
|
||||
sinkA,
|
||||
sinkB,
|
||||
unregister: () => {
|
||||
unregisterA();
|
||||
unregisterB();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchWebhookRequest(req: IncomingMessage) {
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handleGoogleChatWebhookRequest(req, res);
|
||||
expect(handled).toBe(true);
|
||||
return res;
|
||||
}
|
||||
|
||||
async function expectVerifiedRoute(params: {
|
||||
request: IncomingMessage;
|
||||
expectedStatus: number;
|
||||
sinkA: ReturnType<typeof vi.fn>;
|
||||
sinkB: ReturnType<typeof vi.fn>;
|
||||
expectedSink: "none" | "A" | "B";
|
||||
}) {
|
||||
const res = await dispatchWebhookRequest(params.request);
|
||||
expect(res.statusCode).toBe(params.expectedStatus);
|
||||
const expectedCounts =
|
||||
params.expectedSink === "A" ? [1, 0] : params.expectedSink === "B" ? [0, 1] : [0, 0];
|
||||
expect(params.sinkA).toHaveBeenCalledTimes(expectedCounts[0]);
|
||||
expect(params.sinkB).toHaveBeenCalledTimes(expectedCounts[1]);
|
||||
}
|
||||
|
||||
function mockSecondVerifierSuccess() {
|
||||
vi.mocked(verifyGoogleChatRequest)
|
||||
.mockResolvedValueOnce({ ok: false, reason: "invalid" })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
}
|
||||
|
||||
describe("Google Chat webhook routing", () => {
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./auth.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("rejects ambiguous routing when multiple targets on the same path verify successfully", async () => {
|
||||
vi.mocked(verifyGoogleChatRequest).mockResolvedValue({ ok: true });
|
||||
|
||||
const { sinkA, sinkB, unregister } = registerTwoTargets();
|
||||
|
||||
try {
|
||||
await expectVerifiedRoute({
|
||||
request: createWebhookRequest({
|
||||
authorization: "Bearer test-token",
|
||||
payload: { type: "ADDED_TO_SPACE", space: { name: "spaces/AAA" } },
|
||||
}),
|
||||
expectedStatus: 401,
|
||||
sinkA,
|
||||
sinkB,
|
||||
expectedSink: "none",
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes to the single verified target when earlier targets fail verification", async () => {
|
||||
mockSecondVerifierSuccess();
|
||||
|
||||
const { logA, logB, sinkA, sinkB, unregister } = registerTwoTargets();
|
||||
|
||||
try {
|
||||
await expectVerifiedRoute({
|
||||
request: createWebhookRequest({
|
||||
authorization: "Bearer test-token",
|
||||
payload: { type: "ADDED_TO_SPACE", space: { name: "spaces/BBB" } },
|
||||
}),
|
||||
expectedStatus: 200,
|
||||
sinkA,
|
||||
sinkB,
|
||||
expectedSink: "B",
|
||||
});
|
||||
expect(logA).not.toHaveBeenCalled();
|
||||
expect(logB).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid bearer before attempting to read the body", async () => {
|
||||
vi.mocked(verifyGoogleChatRequest).mockResolvedValue({ ok: false, reason: "invalid" });
|
||||
const { unregister } = registerTwoTargets();
|
||||
|
||||
try {
|
||||
const req = createHeaderOnlyWebhookRequest({
|
||||
authorization: "Bearer invalid-token",
|
||||
});
|
||||
const onSpy = vi.spyOn(req, "on");
|
||||
const res = await dispatchWebhookRequest(req);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(onSpy.mock.calls.map(([event]) => event)).not.toContain("data");
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("supports add-on requests that provide systemIdToken in the body", async () => {
|
||||
mockSecondVerifierSuccess();
|
||||
const { sinkA, sinkB, unregister } = registerTwoTargets();
|
||||
|
||||
try {
|
||||
await expectVerifiedRoute({
|
||||
request: createWebhookRequest({
|
||||
payload: {
|
||||
commonEventObject: { hostApp: "CHAT" },
|
||||
authorizationEventObject: { systemIdToken: "addon-token" },
|
||||
chat: {
|
||||
eventTime: "2026-03-02T00:00:00.000Z",
|
||||
user: { name: "users/12345", displayName: "Test User" },
|
||||
messagePayload: {
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { text: "Hello from add-on" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
expectedStatus: 200,
|
||||
sinkA,
|
||||
sinkB,
|
||||
expectedSink: "B",
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
});
|
||||
10
extensions/googlechat/src/runtime.ts
Normal file
10
extensions/googlechat/src/runtime.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Googlechat plugin module implements runtime behavior.
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setGoogleChatRuntime, getRuntime: getGoogleChatRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "googlechat",
|
||||
errorMessage: "Google Chat runtime not initialized",
|
||||
});
|
||||
export { getGoogleChatRuntime, setGoogleChatRuntime };
|
||||
61
extensions/googlechat/src/secret-contract.test.ts
Normal file
61
extensions/googlechat/src/secret-contract.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Googlechat tests cover secret contract plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
applyResolvedAssignments,
|
||||
createResolverContext,
|
||||
resolveSecretRefValues,
|
||||
} from "openclaw/plugin-sdk/secret-ref-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectRuntimeConfigAssignments } from "./secret-contract.js";
|
||||
|
||||
describe("googlechat secret contract", () => {
|
||||
it("resolves account serviceAccount SecretRefs for enabled accounts", async () => {
|
||||
const sourceConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
work: {
|
||||
enabled: true,
|
||||
serviceAccountRef: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "GOOGLECHAT_SERVICE_ACCOUNT",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
const resolvedConfig: OpenClawConfig = structuredClone(sourceConfig);
|
||||
const context = createResolverContext({
|
||||
sourceConfig,
|
||||
env: {
|
||||
GOOGLECHAT_SERVICE_ACCOUNT: '{"client_email":"bot@example.com"}',
|
||||
},
|
||||
});
|
||||
|
||||
collectRuntimeConfigAssignments({
|
||||
config: resolvedConfig,
|
||||
defaults: undefined,
|
||||
context,
|
||||
});
|
||||
|
||||
const resolved = await resolveSecretRefValues(
|
||||
context.assignments.map((assignment) => assignment.ref),
|
||||
{
|
||||
config: sourceConfig,
|
||||
env: context.env,
|
||||
cache: context.cache,
|
||||
},
|
||||
);
|
||||
applyResolvedAssignments({
|
||||
assignments: context.assignments,
|
||||
resolved,
|
||||
});
|
||||
|
||||
const workAccount = resolvedConfig.channels?.googlechat?.accounts?.work;
|
||||
expect(workAccount?.serviceAccount).toBe('{"client_email":"bot@example.com"}');
|
||||
expect(context.warnings).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
162
extensions/googlechat/src/secret-contract.ts
Normal file
162
extensions/googlechat/src/secret-contract.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// Googlechat plugin module implements secret contract behavior.
|
||||
import {
|
||||
getChannelSurface,
|
||||
hasOwnProperty,
|
||||
pushAssignment,
|
||||
pushInactiveSurfaceWarning,
|
||||
pushWarning,
|
||||
resolveChannelAccountSurface,
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
|
||||
import { coerceSecretRef } from "openclaw/plugin-sdk/secret-ref-runtime";
|
||||
|
||||
type GoogleChatAccountLike = {
|
||||
serviceAccount?: unknown;
|
||||
serviceAccountRef?: unknown;
|
||||
accounts?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const secretTargetRegistryEntries: import("openclaw/plugin-sdk/channel-secret-basic-runtime").SecretTargetRegistryEntry[] =
|
||||
[
|
||||
{
|
||||
id: "channels.googlechat.accounts.*.serviceAccount",
|
||||
targetType: "channels.googlechat.serviceAccount",
|
||||
targetTypeAliases: ["channels.googlechat.accounts.*.serviceAccount"],
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.googlechat.accounts.*.serviceAccount",
|
||||
refPathPattern: "channels.googlechat.accounts.*.serviceAccountRef",
|
||||
secretShape: "sibling_ref",
|
||||
expectedResolvedValue: "string-or-object",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
accountIdPathSegmentIndex: 3,
|
||||
},
|
||||
{
|
||||
id: "channels.googlechat.serviceAccount",
|
||||
targetType: "channels.googlechat.serviceAccount",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.googlechat.serviceAccount",
|
||||
refPathPattern: "channels.googlechat.serviceAccountRef",
|
||||
secretShape: "sibling_ref",
|
||||
expectedResolvedValue: "string-or-object",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
];
|
||||
|
||||
function resolveSecretInputRef(params: {
|
||||
value: unknown;
|
||||
refValue?: unknown;
|
||||
defaults?: SecretDefaults;
|
||||
}) {
|
||||
const explicitRef = coerceSecretRef(params.refValue, params.defaults);
|
||||
const inlineRef = explicitRef ? null : coerceSecretRef(params.value, params.defaults);
|
||||
return {
|
||||
explicitRef,
|
||||
inlineRef,
|
||||
ref: explicitRef ?? inlineRef,
|
||||
};
|
||||
}
|
||||
|
||||
function collectGoogleChatAccountAssignment(params: {
|
||||
target: GoogleChatAccountLike;
|
||||
path: string;
|
||||
defaults?: SecretDefaults;
|
||||
context: ResolverContext;
|
||||
active?: boolean;
|
||||
inactiveReason?: string;
|
||||
}): void {
|
||||
const { explicitRef, ref } = resolveSecretInputRef({
|
||||
value: params.target.serviceAccount,
|
||||
refValue: params.target.serviceAccountRef,
|
||||
defaults: params.defaults,
|
||||
});
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
if (params.active === false) {
|
||||
pushInactiveSurfaceWarning({
|
||||
context: params.context,
|
||||
path: `${params.path}.serviceAccount`,
|
||||
details: params.inactiveReason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
explicitRef &&
|
||||
params.target.serviceAccount !== undefined &&
|
||||
!coerceSecretRef(params.target.serviceAccount, params.defaults)
|
||||
) {
|
||||
pushWarning(params.context, {
|
||||
code: "SECRETS_REF_OVERRIDES_PLAINTEXT",
|
||||
path: params.path,
|
||||
message: `${params.path}: serviceAccountRef is set; runtime will ignore plaintext serviceAccount.`,
|
||||
});
|
||||
}
|
||||
pushAssignment(params.context, {
|
||||
ref,
|
||||
path: `${params.path}.serviceAccount`,
|
||||
expected: "string-or-object",
|
||||
apply: (value) => {
|
||||
params.target.serviceAccount = value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function collectRuntimeConfigAssignments(params: {
|
||||
config: { channels?: Record<string, unknown> };
|
||||
defaults?: SecretDefaults;
|
||||
context: ResolverContext;
|
||||
}): void {
|
||||
const resolved = getChannelSurface(params.config, "googlechat");
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const googleChat = resolved.channel as GoogleChatAccountLike;
|
||||
const surface = resolveChannelAccountSurface(googleChat as Record<string, unknown>);
|
||||
const topLevelServiceAccountActive = !surface.channelEnabled
|
||||
? false
|
||||
: !surface.hasExplicitAccounts
|
||||
? true
|
||||
: surface.accounts.some(
|
||||
({ account, enabled }) =>
|
||||
enabled &&
|
||||
!hasOwnProperty(account, "serviceAccount") &&
|
||||
!hasOwnProperty(account, "serviceAccountRef"),
|
||||
);
|
||||
collectGoogleChatAccountAssignment({
|
||||
target: googleChat,
|
||||
path: "channels.googlechat",
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
active: topLevelServiceAccountActive,
|
||||
inactiveReason: "no enabled account inherits this top-level Google Chat serviceAccount.",
|
||||
});
|
||||
if (!surface.hasExplicitAccounts) {
|
||||
return;
|
||||
}
|
||||
for (const { accountId, account, enabled } of surface.accounts) {
|
||||
if (
|
||||
!hasOwnProperty(account, "serviceAccount") &&
|
||||
!hasOwnProperty(account, "serviceAccountRef")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
collectGoogleChatAccountAssignment({
|
||||
target: account as GoogleChatAccountLike,
|
||||
path: `channels.googlechat.accounts.${accountId}`,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
active: enabled,
|
||||
inactiveReason: "Google Chat account is disabled.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const channelSecrets = {
|
||||
secretTargetRegistryEntries,
|
||||
collectRuntimeConfigAssignments,
|
||||
};
|
||||
41
extensions/googlechat/src/setup-core.ts
Normal file
41
extensions/googlechat/src/setup-core.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Googlechat plugin module implements setup core behavior.
|
||||
import {
|
||||
createPatchedAccountSetupAdapter,
|
||||
createSetupInputPresenceValidator,
|
||||
} from "openclaw/plugin-sdk/setup-runtime";
|
||||
|
||||
const channel = "googlechat" as const;
|
||||
|
||||
export const googlechatSetupAdapter = createPatchedAccountSetupAdapter({
|
||||
channelKey: channel,
|
||||
validateInput: createSetupInputPresenceValidator({
|
||||
defaultAccountOnlyEnvError:
|
||||
"GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account.",
|
||||
whenNotUseEnv: [
|
||||
{
|
||||
someOf: ["token", "tokenFile"],
|
||||
message: "Google Chat requires --token (service account JSON) or --token-file.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
buildPatch: (input) => {
|
||||
const patch = input.useEnv
|
||||
? {}
|
||||
: input.tokenFile
|
||||
? { serviceAccountFile: input.tokenFile }
|
||||
: input.token
|
||||
? { serviceAccount: input.token }
|
||||
: {};
|
||||
const audienceType = input.audienceType?.trim();
|
||||
const audience = input.audience?.trim();
|
||||
const webhookPath = input.webhookPath?.trim();
|
||||
const webhookUrl = input.webhookUrl?.trim();
|
||||
return {
|
||||
...patch,
|
||||
...(audienceType ? { audienceType } : {}),
|
||||
...(audience ? { audience } : {}),
|
||||
...(webhookPath ? { webhookPath } : {}),
|
||||
...(webhookUrl ? { webhookUrl } : {}),
|
||||
};
|
||||
},
|
||||
});
|
||||
244
extensions/googlechat/src/setup-surface.ts
Normal file
244
extensions/googlechat/src/setup-surface.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
// Googlechat plugin module implements setup surface behavior.
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
applySetupAccountConfigPatch,
|
||||
createPromptParsedAllowFromForAccount,
|
||||
createStandardChannelSetupStatus,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
mergeAllowFromEntries,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
splitSetupEntries,
|
||||
createSetupTranslator,
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizard,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringifiedOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveDefaultGoogleChatAccountId, resolveGoogleChatAccount } from "./accounts.js";
|
||||
|
||||
const t = createSetupTranslator();
|
||||
|
||||
const channel = "googlechat" as const;
|
||||
const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
|
||||
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
|
||||
const USE_ENV_FLAG = "__googlechatUseEnv";
|
||||
const AUTH_METHOD_FLAG = "__googlechatAuthMethod";
|
||||
|
||||
type GoogleChatTextInput = NonNullable<ChannelSetupWizard["textInputs"]>[number];
|
||||
type GoogleChatTextInputKey = GoogleChatTextInput["inputKey"];
|
||||
|
||||
const promptAllowFrom = createPromptParsedAllowFromForAccount({
|
||||
defaultAccountId: resolveDefaultGoogleChatAccountId,
|
||||
message: t("wizard.googlechat.allowFromPrompt"),
|
||||
placeholder: "users/123456789, name@example.com",
|
||||
parseEntries: (raw) => ({
|
||||
entries: mergeAllowFromEntries(undefined, splitSetupEntries(raw)),
|
||||
}),
|
||||
getExistingAllowFrom: ({ cfg, accountId }) =>
|
||||
resolveGoogleChatAccount({ cfg, accountId }).config.dm?.allowFrom ?? [],
|
||||
applyAllowFrom: ({ cfg, accountId, allowFrom }) =>
|
||||
applySetupAccountConfigPatch({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: {
|
||||
dm: {
|
||||
...resolveGoogleChatAccount({ cfg, accountId }).config.dm,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const googlechatDmPolicy: ChannelSetupDmPolicy = {
|
||||
label: "Google Chat",
|
||||
channel,
|
||||
policyKey: "channels.googlechat.dm.policy",
|
||||
allowFromKey: "channels.googlechat.dm.allowFrom",
|
||||
resolveConfigKeys: (cfg, accountId) =>
|
||||
(accountId ?? resolveDefaultGoogleChatAccountId(cfg)) !== DEFAULT_ACCOUNT_ID
|
||||
? {
|
||||
policyKey: `channels.googlechat.accounts.${accountId ?? resolveDefaultGoogleChatAccountId(cfg)}.dm.policy`,
|
||||
allowFromKey: `channels.googlechat.accounts.${accountId ?? resolveDefaultGoogleChatAccountId(cfg)}.dm.allowFrom`,
|
||||
}
|
||||
: {
|
||||
policyKey: "channels.googlechat.dm.policy",
|
||||
allowFromKey: "channels.googlechat.dm.allowFrom",
|
||||
},
|
||||
getCurrent: (cfg, accountId) =>
|
||||
resolveGoogleChatAccount({
|
||||
cfg,
|
||||
accountId: accountId ?? resolveDefaultGoogleChatAccountId(cfg),
|
||||
}).config.dm?.policy ?? "pairing",
|
||||
setPolicy: (cfg, policy, accountId) => {
|
||||
const resolvedAccountId = accountId ?? resolveDefaultGoogleChatAccountId(cfg);
|
||||
const currentDm = resolveGoogleChatAccount({
|
||||
cfg,
|
||||
accountId: resolvedAccountId,
|
||||
}).config.dm;
|
||||
return applySetupAccountConfigPatch({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId: resolvedAccountId,
|
||||
patch: {
|
||||
dm: {
|
||||
...currentDm,
|
||||
policy,
|
||||
...(policy === "open" ? { allowFrom: addWildcardAllowFrom(currentDm?.allowFrom) } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
promptAllowFrom,
|
||||
};
|
||||
|
||||
function createServiceAccountTextInput(params: {
|
||||
inputKey: GoogleChatTextInputKey;
|
||||
message: string;
|
||||
placeholder: string;
|
||||
authMethod: "file" | "inline";
|
||||
patchKey: "serviceAccountFile" | "serviceAccount";
|
||||
}): GoogleChatTextInput {
|
||||
return {
|
||||
inputKey: params.inputKey,
|
||||
message: params.message,
|
||||
placeholder: params.placeholder,
|
||||
shouldPrompt: ({ credentialValues }) =>
|
||||
credentialValues[USE_ENV_FLAG] !== "1" &&
|
||||
credentialValues[AUTH_METHOD_FLAG] === params.authMethod,
|
||||
validate: ({ value }) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
|
||||
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
|
||||
applySet: async ({ cfg, accountId, value }) =>
|
||||
applySetupAccountConfigPatch({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: { [params.patchKey]: value },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const googlechatSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
status: createStandardChannelSetupStatus({
|
||||
channelLabel: "Google Chat",
|
||||
configuredLabel: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredLabel: t("wizard.channels.statusNeedsServiceAccount"),
|
||||
configuredHint: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredHint: t("wizard.channels.statusNeedsAuth"),
|
||||
includeStatusLine: true,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none",
|
||||
}),
|
||||
introNote: {
|
||||
title: t("wizard.googlechat.setupTitle"),
|
||||
lines: [
|
||||
t("wizard.googlechat.setupServiceAccount"),
|
||||
t("wizard.googlechat.setupScopes"),
|
||||
t("wizard.googlechat.setupAudience"),
|
||||
t("wizard.channels.docs", { link: formatDocsLink("/channels/googlechat", "googlechat") }),
|
||||
],
|
||||
},
|
||||
prepare: async ({ cfg, accountId, credentialValues, prompter }) => {
|
||||
const envReady =
|
||||
accountId === DEFAULT_ACCOUNT_ID &&
|
||||
(Boolean(process.env[ENV_SERVICE_ACCOUNT]) || Boolean(process.env[ENV_SERVICE_ACCOUNT_FILE]));
|
||||
if (envReady) {
|
||||
const useEnv = await prompter.confirm({
|
||||
message: t("wizard.googlechat.useEnvPrompt"),
|
||||
initialValue: true,
|
||||
});
|
||||
if (useEnv) {
|
||||
return {
|
||||
cfg: applySetupAccountConfigPatch({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: {},
|
||||
}),
|
||||
credentialValues: {
|
||||
...credentialValues,
|
||||
[USE_ENV_FLAG]: "1",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const method = await prompter.select({
|
||||
message: t("wizard.googlechat.authMethod"),
|
||||
options: [
|
||||
{ value: "file", label: t("wizard.googlechat.serviceAccountFile") },
|
||||
{ value: "inline", label: t("wizard.googlechat.serviceAccountInline") },
|
||||
],
|
||||
initialValue: "file",
|
||||
});
|
||||
|
||||
return {
|
||||
credentialValues: {
|
||||
...credentialValues,
|
||||
[USE_ENV_FLAG]: "0",
|
||||
[AUTH_METHOD_FLAG]: method,
|
||||
},
|
||||
};
|
||||
},
|
||||
credentials: [],
|
||||
textInputs: [
|
||||
createServiceAccountTextInput({
|
||||
inputKey: "tokenFile",
|
||||
message: t("wizard.googlechat.serviceAccountPath"),
|
||||
placeholder: "/path/to/service-account.json",
|
||||
authMethod: "file",
|
||||
patchKey: "serviceAccountFile",
|
||||
}),
|
||||
createServiceAccountTextInput({
|
||||
inputKey: "token",
|
||||
message: t("wizard.googlechat.serviceAccountJson"),
|
||||
placeholder: '{"type":"service_account", ... }',
|
||||
authMethod: "inline",
|
||||
patchKey: "serviceAccount",
|
||||
}),
|
||||
],
|
||||
finalize: async ({ cfg, accountId, prompter }) => {
|
||||
const account = resolveGoogleChatAccount({
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
const audienceType = await prompter.select({
|
||||
message: t("wizard.googlechat.webhookAudienceType"),
|
||||
options: [
|
||||
{ value: "app-url", label: t("wizard.googlechat.appUrlRecommended") },
|
||||
{ value: "project-number", label: t("wizard.googlechat.projectNumber") },
|
||||
],
|
||||
initialValue: account.config.audienceType === "project-number" ? "project-number" : "app-url",
|
||||
});
|
||||
const audience = await prompter.text({
|
||||
message:
|
||||
audienceType === "project-number"
|
||||
? t("wizard.googlechat.projectNumber")
|
||||
: t("wizard.googlechat.appUrl"),
|
||||
placeholder:
|
||||
audienceType === "project-number" ? "1234567890" : "https://your.host/googlechat",
|
||||
initialValue: account.config.audience || undefined,
|
||||
validate: (value) =>
|
||||
normalizeStringifiedOptionalString(value) ? undefined : t("common.required"),
|
||||
});
|
||||
return {
|
||||
cfg: migrateBaseNameToDefaultAccount({
|
||||
cfg: applySetupAccountConfigPatch({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: {
|
||||
audienceType,
|
||||
audience: normalizeOptionalString(audience) ?? "",
|
||||
},
|
||||
}),
|
||||
channelKey: channel,
|
||||
}),
|
||||
};
|
||||
},
|
||||
dmPolicy: googlechatDmPolicy,
|
||||
};
|
||||
638
extensions/googlechat/src/setup.test.ts
Normal file
638
extensions/googlechat/src/setup.test.ts
Normal file
@@ -0,0 +1,638 @@
|
||||
// Googlechat tests cover setup plugin behavior.
|
||||
import {
|
||||
createStartAccountContext,
|
||||
expectLifecyclePatch,
|
||||
expectPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import {
|
||||
createPluginSetupWizardConfigure,
|
||||
createPluginSetupWizardStatus,
|
||||
createTestWizardPrompter,
|
||||
runSetupWizardConfigure,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
||||
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
listGoogleChatAccountIds,
|
||||
resolveGoogleChatAccount,
|
||||
resolveDefaultGoogleChatAccountId,
|
||||
type ResolvedGoogleChatAccount,
|
||||
} from "./accounts.js";
|
||||
import { startGoogleChatGatewayAccount } from "./gateway.js";
|
||||
import { googlechatSetupAdapter } from "./setup-core.js";
|
||||
import { googlechatSetupWizard } from "./setup-surface.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
startGoogleChatMonitor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./channel.runtime.js", () => ({
|
||||
googleChatChannelRuntime: {
|
||||
resolveGoogleChatWebhookPath: ({
|
||||
account,
|
||||
}: {
|
||||
account: { config: { webhookPath?: string } };
|
||||
}) => account.config.webhookPath ?? "/googlechat",
|
||||
startGoogleChatMonitor: hoisted.startGoogleChatMonitor,
|
||||
},
|
||||
}));
|
||||
|
||||
const googlechatSetupPlugin = {
|
||||
id: "googlechat",
|
||||
meta: {
|
||||
label: "Google Chat",
|
||||
},
|
||||
config: {
|
||||
defaultAccountId: resolveDefaultGoogleChatAccountId,
|
||||
listAccountIds: listGoogleChatAccountIds,
|
||||
},
|
||||
setupWizard: googlechatSetupWizard,
|
||||
} as never;
|
||||
|
||||
const googlechatConfigure = createPluginSetupWizardConfigure(googlechatSetupPlugin);
|
||||
const googlechatStatus = createPluginSetupWizardStatus(googlechatSetupPlugin);
|
||||
|
||||
function buildAccount(): ResolvedGoogleChatAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "inline",
|
||||
credentials: {},
|
||||
config: {
|
||||
webhookPath: "/googlechat",
|
||||
webhookUrl: "https://example.com/googlechat",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForGoogleChatMonitorStarted() {
|
||||
await vi.waitFor(() => expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce());
|
||||
}
|
||||
|
||||
describe("googlechat setup", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./channel.runtime.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("rejects env auth for non-default accounts", () => {
|
||||
if (!googlechatSetupAdapter.validateInput) {
|
||||
throw new Error("Expected googlechatSetupAdapter.validateInput to be defined");
|
||||
}
|
||||
expect(
|
||||
googlechatSetupAdapter.validateInput({
|
||||
accountId: "secondary",
|
||||
input: { useEnv: true },
|
||||
} as never),
|
||||
).toBe("GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account.");
|
||||
});
|
||||
|
||||
it("requires inline or file credentials when env auth is not used", () => {
|
||||
if (!googlechatSetupAdapter.validateInput) {
|
||||
throw new Error("Expected googlechatSetupAdapter.validateInput to be defined");
|
||||
}
|
||||
expect(
|
||||
googlechatSetupAdapter.validateInput({
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input: { useEnv: false, token: "", tokenFile: "" },
|
||||
} as never),
|
||||
).toBe("Google Chat requires --token (service account JSON) or --token-file.");
|
||||
});
|
||||
|
||||
it("builds a patch from token-file and trims optional webhook fields", () => {
|
||||
if (!googlechatSetupAdapter.applyAccountConfig) {
|
||||
throw new Error("Expected googlechatSetupAdapter.applyAccountConfig to be defined");
|
||||
}
|
||||
expect(
|
||||
googlechatSetupAdapter.applyAccountConfig({
|
||||
cfg: { channels: { googlechat: {} } },
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input: {
|
||||
name: "Default",
|
||||
tokenFile: "/tmp/googlechat.json",
|
||||
audienceType: " app-url ",
|
||||
audience: " https://example.com/googlechat ",
|
||||
webhookPath: " /googlechat ",
|
||||
webhookUrl: " https://example.com/googlechat/hook ",
|
||||
},
|
||||
} as never),
|
||||
).toEqual({
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
name: "Default",
|
||||
serviceAccountFile: "/tmp/googlechat.json",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
webhookPath: "/googlechat",
|
||||
webhookUrl: "https://example.com/googlechat/hook",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers inline token patch when token-file is absent", () => {
|
||||
if (!googlechatSetupAdapter.applyAccountConfig) {
|
||||
throw new Error("Expected googlechatSetupAdapter.applyAccountConfig to be defined");
|
||||
}
|
||||
expect(
|
||||
googlechatSetupAdapter.applyAccountConfig({
|
||||
cfg: { channels: { googlechat: {} } },
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input: {
|
||||
name: "Default",
|
||||
token: { client_email: "bot@example.com" },
|
||||
},
|
||||
} as never),
|
||||
).toEqual({
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
name: "Default",
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("configures service-account auth and webhook audience", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Service account JSON path") {
|
||||
return "/tmp/googlechat-service-account.json";
|
||||
}
|
||||
if (message === "App URL") {
|
||||
return "https://example.com/googlechat";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
});
|
||||
|
||||
const result = await runSetupWizardConfigure({
|
||||
configure: googlechatConfigure,
|
||||
cfg: {} as OpenClawConfig,
|
||||
prompter,
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(result.accountId).toBe("default");
|
||||
expect(result.cfg.channels?.googlechat?.enabled).toBe(true);
|
||||
expect(result.cfg.channels?.googlechat?.serviceAccountFile).toBe(
|
||||
"/tmp/googlechat-service-account.json",
|
||||
);
|
||||
expect(result.cfg.channels?.googlechat?.audienceType).toBe("app-url");
|
||||
expect(result.cfg.channels?.googlechat?.audience).toBe("https://example.com/googlechat");
|
||||
});
|
||||
|
||||
it("reads the named-account DM policy instead of the channel root", () => {
|
||||
expect(
|
||||
googlechatSetupWizard.dmPolicy?.getCurrent(
|
||||
{
|
||||
channels: {
|
||||
googlechat: {
|
||||
dm: {
|
||||
policy: "disabled",
|
||||
},
|
||||
accounts: {
|
||||
alerts: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
"alerts",
|
||||
),
|
||||
).toBe("allowlist");
|
||||
});
|
||||
|
||||
it("reports configured state for the selected account instead of any account", async () => {
|
||||
const status = await googlechatStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
serviceAccount: { client_email: "default@example.com" },
|
||||
},
|
||||
alerts: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountOverrides: {
|
||||
googlechat: "alerts",
|
||||
},
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("reports configured state for the configured defaultAccount instead of any account", async () => {
|
||||
const status = await googlechatStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
googlechat: {
|
||||
defaultAccount: "alerts",
|
||||
accounts: {
|
||||
default: {
|
||||
serviceAccount: { client_email: "default@example.com" },
|
||||
},
|
||||
alerts: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountOverrides: {},
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("reports account-scoped config keys for named accounts", () => {
|
||||
expect(googlechatSetupWizard.dmPolicy?.resolveConfigKeys?.({}, "alerts")).toEqual({
|
||||
policyKey: "channels.googlechat.accounts.alerts.dm.policy",
|
||||
allowFromKey: "channels.googlechat.accounts.alerts.dm.allowFrom",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted DM policy account context", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
defaultAccount: "alerts",
|
||||
dm: {
|
||||
policy: "disabled",
|
||||
},
|
||||
accounts: {
|
||||
alerts: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(googlechatSetupWizard.dmPolicy?.getCurrent(cfg)).toBe("allowlist");
|
||||
expect(googlechatSetupWizard.dmPolicy?.resolveConfigKeys?.(cfg)).toEqual({
|
||||
policyKey: "channels.googlechat.accounts.alerts.dm.policy",
|
||||
allowFromKey: "channels.googlechat.accounts.alerts.dm.allowFrom",
|
||||
});
|
||||
|
||||
const next = googlechatSetupWizard.dmPolicy?.setPolicy(cfg, "open");
|
||||
expect(next?.channels?.googlechat?.dm?.policy).toBe("disabled");
|
||||
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.policy).toBe("open");
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted allowFrom prompt context", async () => {
|
||||
const prompter = {
|
||||
note: vi.fn(async () => {}),
|
||||
text: vi.fn(async () => "users/123456789"),
|
||||
};
|
||||
|
||||
const next = await googlechatSetupWizard.dmPolicy?.promptAllowFrom?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
googlechat: {
|
||||
defaultAccount: "alerts",
|
||||
dm: {
|
||||
allowFrom: ["users/root"],
|
||||
},
|
||||
accounts: {
|
||||
alerts: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: {
|
||||
allowFrom: ["users/alerts"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
prompter: prompter as any,
|
||||
});
|
||||
|
||||
expect(next?.channels?.googlechat?.dm?.allowFrom).toEqual(["users/root"]);
|
||||
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.allowFrom).toEqual([
|
||||
"users/123456789",
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes open DM policy to the named account and preserves inherited allowFrom with "*"', () => {
|
||||
const next = googlechatSetupWizard.dmPolicy?.setPolicy(
|
||||
{
|
||||
channels: {
|
||||
googlechat: {
|
||||
dm: {
|
||||
allowFrom: ["users/123"],
|
||||
},
|
||||
accounts: {
|
||||
alerts: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
"open",
|
||||
"alerts",
|
||||
);
|
||||
|
||||
expect(next?.channels?.googlechat?.dm?.policy).toBeUndefined();
|
||||
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.policy).toBe("open");
|
||||
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.allowFrom).toEqual(["users/123", "*"]);
|
||||
});
|
||||
|
||||
it("keeps startAccount pending until abort, then unregisters", async () => {
|
||||
const unregister = vi.fn();
|
||||
hoisted.startGoogleChatMonitor.mockResolvedValue(unregister);
|
||||
|
||||
const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({
|
||||
startAccount: startGoogleChatGatewayAccount,
|
||||
account: buildAccount(),
|
||||
});
|
||||
await expectPendingUntilAbort({
|
||||
waitForStarted: waitForGoogleChatMonitorStarted,
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
assertBeforeAbort: () => {
|
||||
expect(unregister).not.toHaveBeenCalled();
|
||||
},
|
||||
assertAfterAbort: () => {
|
||||
expect(unregister).toHaveBeenCalledOnce();
|
||||
},
|
||||
});
|
||||
expectLifecyclePatch(patches, { running: true });
|
||||
expectLifecyclePatch(patches, { running: false });
|
||||
});
|
||||
|
||||
it("clears running status when monitor startup fails", async () => {
|
||||
hoisted.startGoogleChatMonitor.mockRejectedValue(new Error("webhook bind failed"));
|
||||
const patches: ChannelAccountSnapshot[] = [];
|
||||
|
||||
const task = startGoogleChatGatewayAccount(
|
||||
createStartAccountContext({
|
||||
account: buildAccount(),
|
||||
statusPatchSink: (next) => patches.push({ ...next }),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(task).rejects.toThrow("webhook bind failed");
|
||||
expectLifecyclePatch(patches, { running: true });
|
||||
expectLifecyclePatch(patches, { running: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGoogleChatAccount", () => {
|
||||
it("parses default-account env JSON credentials only when they decode to an object", () => {
|
||||
vi.stubEnv("GOOGLE_CHAT_SERVICE_ACCOUNT", '{"client_email":"bot@example.com"}');
|
||||
|
||||
const resolved = resolveGoogleChatAccount({
|
||||
cfg: { channels: { googlechat: {} } },
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolved.credentialSource).toBe("env");
|
||||
expect(resolved.credentials).toEqual({ client_email: "bot@example.com" });
|
||||
});
|
||||
|
||||
it("ignores env JSON credentials when they decode to a non-object value", () => {
|
||||
vi.stubEnv("GOOGLE_CHAT_SERVICE_ACCOUNT", '["not","an","object"]');
|
||||
vi.stubEnv("GOOGLE_CHAT_SERVICE_ACCOUNT_FILE", "/tmp/googlechat.json");
|
||||
|
||||
const resolved = resolveGoogleChatAccount({
|
||||
cfg: { channels: { googlechat: {} } },
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolved.credentialSource).toBe("env");
|
||||
expect(resolved.credentials).toBeUndefined();
|
||||
expect(resolved.credentialsFile).toBe("/tmp/googlechat.json");
|
||||
});
|
||||
|
||||
it("inherits shared defaults from accounts.default for named accounts", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
webhookPath: "/googlechat",
|
||||
},
|
||||
andy: {
|
||||
serviceAccountFile: "/tmp/andy-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" });
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
expect(resolved.config.audience).toBe("https://example.com/googlechat");
|
||||
expect(resolved.config.webhookPath).toBe("/googlechat");
|
||||
expect(resolved.config.serviceAccountFile).toBe("/tmp/andy-sa.json");
|
||||
});
|
||||
|
||||
it("prefers top-level and account overrides over accounts.default", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
audienceType: "project-number",
|
||||
audience: "1234567890",
|
||||
accounts: {
|
||||
default: {
|
||||
audienceType: "app-url",
|
||||
audience: "https://default.example.com/googlechat",
|
||||
webhookPath: "/googlechat-default",
|
||||
},
|
||||
april: {
|
||||
webhookPath: "/googlechat-april",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "april" });
|
||||
expect(resolved.config.audienceType).toBe("project-number");
|
||||
expect(resolved.config.audience).toBe("1234567890");
|
||||
expect(resolved.config.webhookPath).toBe("/googlechat-april");
|
||||
});
|
||||
|
||||
it("merges account bot loop protection over top-level defaults field-by-field", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
botLoopProtection: {
|
||||
maxEventsPerWindow: 8,
|
||||
windowSeconds: 120,
|
||||
cooldownSeconds: 240,
|
||||
},
|
||||
accounts: {
|
||||
april: {
|
||||
webhookPath: "/googlechat-april",
|
||||
botLoopProtection: {
|
||||
maxEventsPerWindow: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "april" });
|
||||
expect(resolved.config.botLoopProtection).toEqual({
|
||||
maxEventsPerWindow: 3,
|
||||
windowSeconds: 120,
|
||||
cooldownSeconds: 240,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges account bot loop protection over accounts.default field-by-field", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
webhookPath: "/googlechat",
|
||||
botLoopProtection: {
|
||||
windowSeconds: 120,
|
||||
cooldownSeconds: 240,
|
||||
},
|
||||
},
|
||||
april: {
|
||||
webhookPath: "/googlechat-april",
|
||||
botLoopProtection: {
|
||||
maxEventsPerWindow: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "april" });
|
||||
expect(resolved.config.botLoopProtection).toEqual({
|
||||
maxEventsPerWindow: 3,
|
||||
windowSeconds: 120,
|
||||
cooldownSeconds: 240,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inherit disabled state from accounts.default for named accounts", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
enabled: false,
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
andy: {
|
||||
serviceAccountFile: "/tmp/andy-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" });
|
||||
expect(resolved.enabled).toBe(true);
|
||||
expect(resolved.config.enabled).toBeUndefined();
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
});
|
||||
|
||||
it("does not inherit default-account credentials into named accounts", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
serviceAccountRef: {
|
||||
source: "env",
|
||||
provider: "test",
|
||||
id: "default-sa",
|
||||
},
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
andy: {
|
||||
serviceAccountFile: "/tmp/andy-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" });
|
||||
expect(resolved.credentialSource).toBe("file");
|
||||
expect(resolved.credentialsFile).toBe("/tmp/andy-sa.json");
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
});
|
||||
|
||||
it("does not inherit dangerous name matching from accounts.default", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
dangerouslyAllowNameMatching: true,
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
andy: {
|
||||
serviceAccountFile: "/tmp/andy-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" });
|
||||
expect(resolved.config.dangerouslyAllowNameMatching).toBeUndefined();
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
defaultAccount: "alerts",
|
||||
accounts: {
|
||||
alerts: {
|
||||
serviceAccountFile: "/tmp/alerts-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg });
|
||||
expect(resolved.accountId).toBe("alerts");
|
||||
expect(resolved.credentialSource).toBe("file");
|
||||
expect(resolved.credentialsFile).toBe("/tmp/alerts-sa.json");
|
||||
});
|
||||
});
|
||||
704
extensions/googlechat/src/targets.test.ts
Normal file
704
extensions/googlechat/src/targets.test.ts
Normal file
@@ -0,0 +1,704 @@
|
||||
// Googlechat tests cover targets plugin behavior.
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { downloadGoogleChatMedia, sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js";
|
||||
import {
|
||||
clearGoogleChatApprovalCardBindingsForTest,
|
||||
registerGoogleChatManualApprovalFollowupSuppression,
|
||||
} from "./approval-card-actions.js";
|
||||
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
|
||||
import {
|
||||
isGoogleChatSpaceTarget,
|
||||
isGoogleChatUserTarget,
|
||||
normalizeGoogleChatTarget,
|
||||
} from "./targets.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buildHostnameAllowlistPolicyFromSuffixAllowlist: vi.fn((hosts: string[]) => ({
|
||||
hostnameAllowlist: hosts,
|
||||
})),
|
||||
fetchWithSsrFGuard: vi.fn(async (params: { url: string; init?: RequestInit }) => ({
|
||||
response: await fetch(params.url, params.init),
|
||||
release: async () => {},
|
||||
})),
|
||||
googleAuthCtor: vi.fn(),
|
||||
gaxiosCtor: vi.fn(),
|
||||
getAccessToken: vi.fn().mockResolvedValue({ token: "access-token" }),
|
||||
oauthCtor: vi.fn(),
|
||||
verifySignedJwtWithCertsAsync: vi.fn(),
|
||||
verifyIdToken: vi.fn(),
|
||||
getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => {
|
||||
return {
|
||||
buildHostnameAllowlistPolicyFromSuffixAllowlist:
|
||||
mocks.buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
||||
fetchWithSsrFGuard: mocks.fetchWithSsrFGuard,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("gaxios", () => ({
|
||||
Gaxios: class {
|
||||
defaults: unknown;
|
||||
interceptors = {
|
||||
request: { add: vi.fn() },
|
||||
response: { add: vi.fn() },
|
||||
};
|
||||
|
||||
constructor(defaults?: unknown) {
|
||||
this.defaults = defaults;
|
||||
mocks.gaxiosCtor(defaults);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("google-auth-library", () => ({
|
||||
GoogleAuth: class {
|
||||
constructor(options?: unknown) {
|
||||
mocks.googleAuthCtor(options);
|
||||
}
|
||||
|
||||
getClient = vi.fn().mockResolvedValue({
|
||||
getAccessToken: mocks.getAccessToken,
|
||||
});
|
||||
},
|
||||
OAuth2Client: class {
|
||||
constructor(options?: unknown) {
|
||||
mocks.oauthCtor(options);
|
||||
}
|
||||
|
||||
verifyIdToken = mocks.verifyIdToken;
|
||||
verifySignedJwtWithCertsAsync = mocks.verifySignedJwtWithCertsAsync;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./auth.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./auth.js")>("./auth.js");
|
||||
return {
|
||||
...actual,
|
||||
getGoogleChatAccessToken: mocks.getGoogleChatAccessToken,
|
||||
};
|
||||
});
|
||||
|
||||
const authActual = await vi.importActual<typeof import("./auth.js")>("./auth.js");
|
||||
const { testing: authTesting, getGoogleChatAccessToken, verifyGoogleChatRequest } = authActual;
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
|
||||
vi.doUnmock("gaxios");
|
||||
vi.doUnmock("google-auth-library");
|
||||
vi.doUnmock("./auth.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
const account = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "inline",
|
||||
config: {},
|
||||
} as ResolvedGoogleChatAccount;
|
||||
|
||||
function stubSuccessfulSend(name: string, threadName?: string) {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ name, ...(threadName ? { thread: { name: threadName } } : {}) }),
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
async function expectDownloadToRejectForResponse(
|
||||
response: Response,
|
||||
expected: string | RegExp = /max bytes/i,
|
||||
) {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
||||
await expect(
|
||||
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
|
||||
).rejects.toThrow(expected);
|
||||
}
|
||||
|
||||
function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0): unknown {
|
||||
const call = mock.mock.calls[callIndex];
|
||||
if (!call) {
|
||||
throw new Error(`Expected mock call ${callIndex}`);
|
||||
}
|
||||
return call[argIndex];
|
||||
}
|
||||
|
||||
describe("normalizeGoogleChatTarget", () => {
|
||||
it("normalizes provider prefixes", () => {
|
||||
expect(normalizeGoogleChatTarget("googlechat:users/123")).toBe("users/123");
|
||||
expect(normalizeGoogleChatTarget("google-chat:spaces/AAA")).toBe("spaces/AAA");
|
||||
expect(normalizeGoogleChatTarget("gchat:user:User@Example.com")).toBe("users/user@example.com");
|
||||
});
|
||||
|
||||
it("normalizes email targets to users/<email>", () => {
|
||||
expect(normalizeGoogleChatTarget("User@Example.com")).toBe("users/user@example.com");
|
||||
expect(normalizeGoogleChatTarget("users/User@Example.com")).toBe("users/user@example.com");
|
||||
});
|
||||
|
||||
it("preserves space targets", () => {
|
||||
expect(normalizeGoogleChatTarget("space:spaces/BBB")).toBe("spaces/BBB");
|
||||
expect(normalizeGoogleChatTarget("spaces/CCC")).toBe("spaces/CCC");
|
||||
});
|
||||
});
|
||||
|
||||
describe("target helpers", () => {
|
||||
it("detects user and space targets", () => {
|
||||
expect(isGoogleChatUserTarget("users/abc")).toBe(true);
|
||||
expect(isGoogleChatSpaceTarget("spaces/abc")).toBe(true);
|
||||
expect(isGoogleChatUserTarget("spaces/abc")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechat group policy", () => {
|
||||
it("uses generic channel group policy helpers", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
groups: {
|
||||
"spaces/AAA": {
|
||||
requireMention: false,
|
||||
},
|
||||
"*": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/AAA" })).toBe(false);
|
||||
expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/BBB" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadGoogleChatMedia", () => {
|
||||
afterEach(() => {
|
||||
clearGoogleChatApprovalCardBindingsForTest();
|
||||
authTesting.resetGoogleChatAuthForTests();
|
||||
mocks.fetchWithSsrFGuard.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("rejects when content-length exceeds max bytes", async () => {
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const response = new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-length": "50", "content-type": "application/octet-stream" },
|
||||
});
|
||||
await expectDownloadToRejectForResponse(response);
|
||||
});
|
||||
|
||||
it("rejects malformed content-length before reading media", async () => {
|
||||
const arrayBuffer = vi.fn(async () => new ArrayBuffer(0));
|
||||
const response = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({
|
||||
"content-length": "0x3",
|
||||
"content-type": "application/octet-stream",
|
||||
}),
|
||||
arrayBuffer,
|
||||
} as unknown as Response;
|
||||
|
||||
await expectDownloadToRejectForResponse(response, "invalid content-length header: 0x3");
|
||||
expect(arrayBuffer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects when streamed payload exceeds max bytes", async () => {
|
||||
const chunks = [new Uint8Array(6), new Uint8Array(6)];
|
||||
let index = 0;
|
||||
const body = new ReadableStream({
|
||||
pull(controller) {
|
||||
if (index < chunks.length) {
|
||||
controller.enqueue(chunks[index++]);
|
||||
} else {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
const response = new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
});
|
||||
await expectDownloadToRejectForResponse(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendGoogleChatMessage", () => {
|
||||
afterEach(() => {
|
||||
authTesting.resetGoogleChatAuthForTests();
|
||||
mocks.fetchWithSsrFGuard.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("adds messageReplyOption when sending to an existing thread", async () => {
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/123", "spaces/AAA/threads/xyz");
|
||||
|
||||
const result = await sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
thread: "spaces/AAA/threads/xyz",
|
||||
});
|
||||
|
||||
const url = mockCallArg(fetchMock);
|
||||
const init = mockCallArg(fetchMock, 0, 1) as RequestInit | undefined;
|
||||
expect(String(url)).toContain("messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD");
|
||||
if (typeof init?.body !== "string") {
|
||||
throw new Error("Expected Google Chat request body");
|
||||
}
|
||||
const body = JSON.parse(init.body) as {
|
||||
text?: unknown;
|
||||
thread?: { name?: unknown };
|
||||
};
|
||||
expect(body.text).toBe("hello");
|
||||
expect(body.thread?.name).toBe("spaces/AAA/threads/xyz");
|
||||
expect(result).toEqual({
|
||||
messageName: "spaces/AAA/messages/123",
|
||||
threadName: "spaces/AAA/threads/xyz",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set messageReplyOption for non-thread sends", async () => {
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/124");
|
||||
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
const url = mockCallArg(fetchMock);
|
||||
expect(String(url)).not.toContain("messageReplyOption=");
|
||||
});
|
||||
|
||||
it("sends cardsV2 with the text fallback", async () => {
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/125");
|
||||
const cardsV2 = [
|
||||
{
|
||||
cardId: "approval",
|
||||
card: {
|
||||
header: { title: "Approval" },
|
||||
sections: [{ widgets: [{ textParagraph: { text: "Approve?" } }] }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "Approval required",
|
||||
cardsV2,
|
||||
});
|
||||
|
||||
const init = mockCallArg(fetchMock, 0, 1) as RequestInit | undefined;
|
||||
if (typeof init?.body !== "string") {
|
||||
throw new Error("Expected Google Chat request body");
|
||||
}
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
text: "Approval required",
|
||||
cardsV2,
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses text-only duplicate manual approval follow-ups at the API send boundary", async () => {
|
||||
registerGoogleChatManualApprovalFollowupSuppression({
|
||||
approvalId: "12345678-1234-1234-1234-123456789012",
|
||||
approvalKind: "exec",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
const result = await sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "Please reply with:\n/approve 12345678 allow-once",
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mocks.fetchWithSsrFGuard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports malformed send JSON with a stable API error", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response("{ nope", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
}),
|
||||
).rejects.toThrow("Google Chat API request failed: malformed JSON response");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateGoogleChatMessage", () => {
|
||||
afterEach(() => {
|
||||
authTesting.resetGoogleChatAuthForTests();
|
||||
mocks.fetchWithSsrFGuard.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("updates text and cardsV2 with a matching update mask", async () => {
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/123");
|
||||
const cardsV2 = [
|
||||
{
|
||||
cardId: "approval",
|
||||
card: {
|
||||
header: { title: "Resolved" },
|
||||
sections: [{ widgets: [{ textParagraph: { text: "Done" } }] }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await updateGoogleChatMessage({
|
||||
account,
|
||||
messageName: "spaces/AAA/messages/123",
|
||||
text: "Resolved",
|
||||
cardsV2,
|
||||
});
|
||||
|
||||
expect(String(mockCallArg(fetchMock))).toContain(
|
||||
"spaces/AAA/messages/123?updateMask=text,cardsV2",
|
||||
);
|
||||
const init = mockCallArg(fetchMock, 0, 1) as RequestInit | undefined;
|
||||
if (typeof init?.body !== "string") {
|
||||
throw new Error("Expected Google Chat request body");
|
||||
}
|
||||
expect(JSON.parse(init.body)).toEqual({ text: "Resolved", cardsV2 });
|
||||
});
|
||||
});
|
||||
|
||||
function mockTicket(payload: Record<string, unknown>) {
|
||||
mocks.verifyIdToken.mockResolvedValue({
|
||||
getPayload: () => payload,
|
||||
});
|
||||
}
|
||||
|
||||
describe("verifyGoogleChatRequest", () => {
|
||||
afterEach(() => {
|
||||
authTesting.resetGoogleChatAuthForTests();
|
||||
mocks.getAccessToken.mockClear();
|
||||
mocks.gaxiosCtor.mockClear();
|
||||
mocks.googleAuthCtor.mockClear();
|
||||
mocks.oauthCtor.mockClear();
|
||||
});
|
||||
|
||||
it("injects a scoped transporter into GoogleAuth access-token clients", async () => {
|
||||
await expect(
|
||||
getGoogleChatAccessToken({
|
||||
...account,
|
||||
credentials: {
|
||||
auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs",
|
||||
auth_uri: "https://accounts.google.com/o/oauth2/auth",
|
||||
client_email: "bot@example.iam.gserviceaccount.com",
|
||||
private_key: "key",
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
type: "service_account",
|
||||
universe_domain: "googleapis.com",
|
||||
},
|
||||
}),
|
||||
).resolves.toBe("access-token");
|
||||
|
||||
const googleAuthOptions = mockCallArg(mocks.googleAuthCtor) as {
|
||||
clientOptions?: { transporter?: { defaults?: { fetchImplementation?: unknown } } };
|
||||
credentials?: { client_email?: string; token_uri?: string };
|
||||
};
|
||||
|
||||
expect(mocks.gaxiosCtor).toHaveBeenCalledOnce();
|
||||
expect(googleAuthOptions.credentials?.client_email).toBe("bot@example.iam.gserviceaccount.com");
|
||||
expect(googleAuthOptions.credentials?.token_uri).toBe("https://oauth2.googleapis.com/token");
|
||||
expect(typeof googleAuthOptions.clientOptions?.transporter?.defaults?.fetchImplementation).toBe(
|
||||
"function",
|
||||
);
|
||||
expect(mocks.getAccessToken).toHaveBeenCalledOnce();
|
||||
expect("window" in globalThis).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts Google Chat app-url tokens from the Chat issuer", async () => {
|
||||
mocks.verifyIdToken.mockReset();
|
||||
mockTicket({
|
||||
email: "chat@system.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
const oauthOptions = mockCallArg(mocks.oauthCtor) as {
|
||||
transporter?: { defaults?: { fetchImplementation?: unknown } };
|
||||
};
|
||||
expect(typeof oauthOptions.transporter?.defaults?.fetchImplementation).toBe("function");
|
||||
});
|
||||
|
||||
it("rejects add-on tokens when no principal binding is configured", async () => {
|
||||
mocks.verifyIdToken.mockReset();
|
||||
mockTicket({
|
||||
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
sub: "principal-1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "missing add-on principal binding",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts add-on tokens only when the bound principal matches", async () => {
|
||||
mocks.verifyIdToken.mockReset();
|
||||
mockTicket({
|
||||
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
sub: "principal-1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
expectedAddOnPrincipal: "principal-1",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects add-on tokens when the bound principal does not match", async () => {
|
||||
mocks.verifyIdToken.mockReset();
|
||||
mockTicket({
|
||||
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
sub: "principal-2",
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
expectedAddOnPrincipal: "principal-1",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "unexpected add-on principal: principal-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Chat certs through the guarded fetch for project-number tokens", async () => {
|
||||
const release = vi.fn();
|
||||
mocks.fetchWithSsrFGuard.mockClear();
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: new Response(JSON.stringify({ "kid-1": "cert-body" }), { status: 200 }),
|
||||
release,
|
||||
});
|
||||
mocks.verifySignedJwtWithCertsAsync.mockReset().mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "project-number",
|
||||
audience: "123456789",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(mocks.fetchWithSsrFGuard).toHaveBeenCalledWith({
|
||||
url: "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com",
|
||||
auditContext: "googlechat.auth.certs",
|
||||
});
|
||||
expect(mocks.verifySignedJwtWithCertsAsync).toHaveBeenCalledWith(
|
||||
"token",
|
||||
{ "kid-1": "cert-body" },
|
||||
"123456789",
|
||||
["chat@system.gserviceaccount.com"],
|
||||
);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports malformed Chat cert JSON with a stable auth error", async () => {
|
||||
authTesting.resetGoogleChatAuthForTests();
|
||||
const release = vi.fn(async () => {});
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: new Response("{ nope", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release,
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "project-number",
|
||||
audience: "123456789",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "Google Chat cert fetch failed: malformed JSON response",
|
||||
});
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe("bounded JSON read (readProviderJsonResponse delegation)", () => {
|
||||
afterEach(() => {
|
||||
authTesting.resetGoogleChatAuthForTests();
|
||||
mocks.fetchWithSsrFGuard.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("cancels oversized cert fetch JSON body via the 16 MiB provider cap", async () => {
|
||||
const ONE_MIB = 1024 * 1024;
|
||||
const TOTAL_CHUNKS = 32;
|
||||
const chunk = new Uint8Array(ONE_MIB);
|
||||
|
||||
let bytesPulled = 0;
|
||||
let canceled = false;
|
||||
const oversizedJson = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
bytesPulled += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
const release = vi.fn(async () => {});
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: oversizedJson,
|
||||
release,
|
||||
});
|
||||
|
||||
const result = await verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "project-number",
|
||||
audience: "123456789",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toMatch(/JSON response exceeds 16777216 bytes/);
|
||||
expect(canceled).toBe(true);
|
||||
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects oversized sendMessage JSON body via the 16 MiB provider cap", async () => {
|
||||
const ONE_MIB = 1024 * 1024;
|
||||
const TOTAL_CHUNKS = 32;
|
||||
const chunk = new Uint8Array(ONE_MIB);
|
||||
|
||||
let bytesPulled = 0;
|
||||
let canceled = false;
|
||||
const oversizedJson = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
bytesPulled += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
const release = vi.fn(async () => {});
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: oversizedJson,
|
||||
release,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
}),
|
||||
).rejects.toThrow(/Google Chat API request failed: JSON response exceeds 16777216 bytes/);
|
||||
|
||||
expect(canceled).toBe(true);
|
||||
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
|
||||
});
|
||||
|
||||
it("caps non-OK sendMessage error bodies before formatting the API error", async () => {
|
||||
const ONE_MIB = 1024 * 1024;
|
||||
const TOTAL_CHUNKS = 32;
|
||||
const chunk = new TextEncoder().encode("x".repeat(ONE_MIB));
|
||||
|
||||
let bytesPulled = 0;
|
||||
let canceled = false;
|
||||
const oversizedError = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
bytesPulled += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
{ status: 500, statusText: "Internal Server Error" },
|
||||
);
|
||||
const release = vi.fn(async () => {});
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: oversizedError,
|
||||
release,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
}),
|
||||
).rejects.toThrow(/^Google Chat API 500: x+/);
|
||||
|
||||
expect(canceled).toBe(true);
|
||||
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
67
extensions/googlechat/src/targets.ts
Normal file
67
extensions/googlechat/src/targets.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// Googlechat plugin module implements targets behavior.
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { findGoogleChatDirectMessage } from "./api.js";
|
||||
|
||||
export function normalizeGoogleChatTarget(raw?: string | null): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, "");
|
||||
const normalized = withoutPrefix
|
||||
.replace(/^user:(users\/)?/i, "users/")
|
||||
.replace(/^space:(spaces\/)?/i, "spaces/");
|
||||
if (isGoogleChatUserTarget(normalized)) {
|
||||
const suffix = normalized.slice("users/".length);
|
||||
return suffix.includes("@") ? `users/${normalizeLowercaseStringOrEmpty(suffix)}` : normalized;
|
||||
}
|
||||
if (isGoogleChatSpaceTarget(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
if (normalized.includes("@")) {
|
||||
return `users/${normalizeLowercaseStringOrEmpty(normalized)}`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function isGoogleChatUserTarget(value: string): boolean {
|
||||
return normalizeLowercaseStringOrEmpty(value).startsWith("users/");
|
||||
}
|
||||
|
||||
export function isGoogleChatSpaceTarget(value: string): boolean {
|
||||
return normalizeLowercaseStringOrEmpty(value).startsWith("spaces/");
|
||||
}
|
||||
|
||||
function stripMessageSuffix(target: string): string {
|
||||
const index = target.indexOf("/messages/");
|
||||
if (index === -1) {
|
||||
return target;
|
||||
}
|
||||
return target.slice(0, index);
|
||||
}
|
||||
|
||||
export async function resolveGoogleChatOutboundSpace(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
target: string;
|
||||
}): Promise<string> {
|
||||
const normalized = normalizeGoogleChatTarget(params.target);
|
||||
if (!normalized) {
|
||||
throw new Error("Missing Google Chat target.");
|
||||
}
|
||||
const base = stripMessageSuffix(normalized);
|
||||
if (isGoogleChatSpaceTarget(base)) {
|
||||
return base;
|
||||
}
|
||||
if (isGoogleChatUserTarget(base)) {
|
||||
const dm = await findGoogleChatDirectMessage({
|
||||
account: params.account,
|
||||
userName: base,
|
||||
});
|
||||
if (!dm?.name) {
|
||||
throw new Error(`No Google Chat DM found for ${base}`);
|
||||
}
|
||||
return dm.name;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
4
extensions/googlechat/src/types.config.ts
Normal file
4
extensions/googlechat/src/types.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Googlechat helper module supports types behavior.
|
||||
import type { GoogleChatAccountConfig, GoogleChatConfig } from "../runtime-api.js";
|
||||
|
||||
export type { GoogleChatAccountConfig, GoogleChatConfig };
|
||||
143
extensions/googlechat/src/types.ts
Normal file
143
extensions/googlechat/src/types.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// Googlechat type declarations define plugin contracts.
|
||||
export type GoogleChatSpace = {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
type?: string;
|
||||
/** Current Google Chat field that replaces the deprecated `type` field. */
|
||||
spaceType?: string;
|
||||
/** True when the space is a 1:1 DM between a user and the Chat app. */
|
||||
singleUserBotDm?: boolean;
|
||||
};
|
||||
|
||||
export type GoogleChatUser = {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
type GoogleChatThread = {
|
||||
name?: string;
|
||||
threadKey?: string;
|
||||
};
|
||||
|
||||
type GoogleChatAttachmentDataRef = {
|
||||
resourceName?: string;
|
||||
attachmentUploadToken?: string;
|
||||
};
|
||||
|
||||
export type GoogleChatAttachment = {
|
||||
name?: string;
|
||||
contentName?: string;
|
||||
contentType?: string;
|
||||
thumbnailUri?: string;
|
||||
downloadUri?: string;
|
||||
source?: string;
|
||||
attachmentDataRef?: GoogleChatAttachmentDataRef;
|
||||
driveDataRef?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type GoogleChatUserMention = {
|
||||
user?: GoogleChatUser;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type GoogleChatAnnotation = {
|
||||
type?: string;
|
||||
startIndex?: number;
|
||||
length?: number;
|
||||
userMention?: GoogleChatUserMention;
|
||||
slashCommand?: Record<string, unknown>;
|
||||
richLinkMetadata?: Record<string, unknown>;
|
||||
customEmojiMetadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type GoogleChatMessage = {
|
||||
name?: string;
|
||||
text?: string;
|
||||
argumentText?: string;
|
||||
sender?: GoogleChatUser;
|
||||
thread?: GoogleChatThread;
|
||||
cardsV2?: GoogleChatCardV2[];
|
||||
attachment?: GoogleChatAttachment[];
|
||||
annotations?: GoogleChatAnnotation[];
|
||||
};
|
||||
|
||||
export type GoogleChatActionParameter = {
|
||||
key?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export type GoogleChatAction = {
|
||||
actionMethodName?: string;
|
||||
parameters?: GoogleChatActionParameter[];
|
||||
};
|
||||
|
||||
export type GoogleChatEvent = {
|
||||
type?: string;
|
||||
eventType?: string;
|
||||
eventTime?: string;
|
||||
space?: GoogleChatSpace;
|
||||
user?: GoogleChatUser;
|
||||
message?: GoogleChatMessage;
|
||||
action?: GoogleChatAction;
|
||||
common?: {
|
||||
invokedFunction?: string;
|
||||
parameters?: Record<string, string>;
|
||||
};
|
||||
commonEventObject?: {
|
||||
invokedFunction?: string;
|
||||
parameters?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
export type GoogleChatReaction = {
|
||||
name?: string;
|
||||
user?: GoogleChatUser;
|
||||
emoji?: { unicode?: string };
|
||||
};
|
||||
|
||||
export type GoogleChatTextParagraphWidget = {
|
||||
textParagraph: {
|
||||
text: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type GoogleChatButtonWidget = {
|
||||
buttonList: {
|
||||
buttons: Array<{
|
||||
text: string;
|
||||
onClick: {
|
||||
action: {
|
||||
function: string;
|
||||
parameters?: GoogleChatActionParameter[];
|
||||
loadIndicator?: "SPINNER" | "NONE";
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export type GoogleChatDividerWidget = { divider: Record<string, never> };
|
||||
|
||||
export type GoogleChatWidget =
|
||||
| GoogleChatTextParagraphWidget
|
||||
| GoogleChatButtonWidget
|
||||
| GoogleChatDividerWidget;
|
||||
|
||||
export type GoogleChatCardV2 = {
|
||||
cardId?: string;
|
||||
card: {
|
||||
header?: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
imageType?: "SQUARE" | "CIRCLE";
|
||||
};
|
||||
sections?: Array<{
|
||||
header?: string;
|
||||
collapsible?: boolean;
|
||||
uncollapsibleWidgetsCount?: number;
|
||||
widgets?: GoogleChatWidget[];
|
||||
}>;
|
||||
};
|
||||
};
|
||||
3
extensions/googlechat/test-api.ts
Normal file
3
extensions/googlechat/test-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Googlechat API module exposes the plugin public contract.
|
||||
export { googlechatPlugin } from "./src/channel.js";
|
||||
export { setGoogleChatRuntime } from "./src/runtime.js";
|
||||
16
extensions/googlechat/tsconfig.json
Normal file
16
extensions/googlechat/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user