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

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:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View 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.

View 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";

View File

@@ -0,0 +1,2 @@
// Googlechat API module exposes the plugin public contract.
export { GoogleChatChannelConfigSchema } from "./src/config-schema.js";

View File

@@ -0,0 +1,2 @@
// Googlechat API module exposes the plugin public contract.
export { googlechatPlugin } from "./src/channel.js";

View 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";

View 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,
};

View File

@@ -0,0 +1,2 @@
// Googlechat API module exposes the plugin public contract.
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";

View 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
View 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"
}
}
}
}

View 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": {}
}
}

View 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
}
}
}

View 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";

View File

@@ -0,0 +1,6 @@
// Googlechat API module exposes the plugin public contract.
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View 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",
},
});

View 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";

View 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);
}

View 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();
});
});

View 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}.`);
},
};

View 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),
};
}
}

View 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 });
});
});

View 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),
});

View 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);
});
});

View 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();
}

View 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);
});
});

View 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;
}

View 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",
);
});
});

View 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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)}`);
},
},
});

View 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);
});
});

View 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,
}),
});

View 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 };

View 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,
},
}),
},
};
}

View 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);
});
});

View 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,
},
});

View 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";

View 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,
};

View 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();

View 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);
});
});

View 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,
}),
},
},
});

View 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);
});
});

View File

@@ -0,0 +1,4 @@
// Googlechat helper module supports config schema behavior.
import { buildChannelConfigSchema, GoogleChatConfigSchema } from "../config-api.js";
export const GoogleChatChannelConfigSchema = buildChannelConfigSchema(GoogleChatConfigSchema);

View 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,
});
});
});

View 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,
};
}

View 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;
},
});

View 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;
}
}

View 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/,
);
});
});

View 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 };

View 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,
});
}

View 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)",
);
});
});

View 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),
};
}

View 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);
});
});

View 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 } : {}),
};
}

View 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,
});
}

View 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);
}

View 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;
};

View 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();
});
});

View 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;
},
});
};
}

View 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" }],
});
});
});

View 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();
});
});

View 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 };

View 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();
}
});
});

View 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 };

View 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([]);
});
});

View 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,
};

View 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 } : {}),
};
},
});

View 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,
};

View 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");
});
});

View 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();
});
});
});

View 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;
}

View File

@@ -0,0 +1,4 @@
// Googlechat helper module supports types behavior.
import type { GoogleChatAccountConfig, GoogleChatConfig } from "../runtime-api.js";
export type { GoogleChatAccountConfig, GoogleChatConfig };

View 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[];
}>;
};
};

View 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";

View 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"
]
}