diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index ad294d5e5885..7d5175c20147 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -162,6 +162,7 @@ Configure your tunnel's ingress rules to only route the webhook path: 4. DM access is pairing by default. Unknown senders receive a pairing code; approve with: - `openclaw pairing approve googlechat ` 5. Group spaces require @-mention by default. Use `botUser` if mention detection needs the app's user name. +6. When an exec or plugin approval request starts from Google Chat and a stable `users/` approver is configured, OpenClaw posts a native Google Chat approval card in the originating space or thread. The card buttons use opaque callback tokens, and the manual `/approve ` prompt is only shown when native approval delivery is unavailable. ## Targets @@ -214,8 +215,9 @@ Notes: - Default webhook path is `/googlechat` if `webhookPath` isn't set. - `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode). - Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled. +- Native approval cards use Google Chat `cardsV2` button clicks, not reaction events. Approvers come from `dm.allowFrom` or `defaultTo` and must be stable numeric `users/` values. - Message actions expose `send` for text and `upload-file` for explicit attachment sends. `upload-file` accepts `media` / `filePath` / `path` plus optional `message`, `filename`, and thread targeting. -- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth). +- `typingIndicator` supports `message` (default), `none`, and `reaction` (reaction requires user OAuth). - Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`). - Bot-authored Google Chat messages are ignored by default. If you intentionally set `allowBots: true`, accepted bot-authored messages use shared [bot loop protection](/channels/bot-loop-protection). Configure `channels.defaults.botLoopProtection`, then override with `channels.googlechat.botLoopProtection` or `channels.googlechat.groups..botLoopProtection` when one space needs a different budget. diff --git a/docs/tools/exec-approvals-advanced.md b/docs/tools/exec-approvals-advanced.md index 9c3d1dc8900d..b3c22e3d4873 100644 --- a/docs/tools/exec-approvals-advanced.md +++ b/docs/tools/exec-approvals-advanced.md @@ -287,6 +287,9 @@ Generic model: - Slack plugin approvals can use Slack's native approval client when the request comes from Slack and Slack plugin approvers resolve; `approvals.plugin` can also route plugin approvals to Slack sessions or targets even when Slack exec approvals are disabled +- Google Chat native approval cards handle exec and plugin approvals that originate from Google + Chat spaces or threads when stable `users/` approvers resolve from `dm.allowFrom` or + `defaultTo`; they do not use reaction events for decisions - WhatsApp and Signal reaction approval delivery are gated by `approvals.exec` and `approvals.plugin`; they do not have `channels..execApprovals` blocks @@ -306,6 +309,8 @@ FAQ: [Why are there two exec approval configs for chat approvals?](/help/faq-fir - Discord: `channels.discord.execApprovals.*` - Slack: `channels.slack.execApprovals.*` - Telegram: `channels.telegram.execApprovals.*` +- Google Chat: configure stable approvers with `channels.googlechat.dm.allowFrom` or + `channels.googlechat.defaultTo`; no `execApprovals` block is required - WhatsApp: use `approvals.exec` and `approvals.plugin` to route approval prompts to WhatsApp - Signal: use `approvals.exec` and `approvals.plugin` to route approval prompts to Signal @@ -325,6 +330,9 @@ Shared behavior: routing, not Slack exec approvers - Slack native buttons preserve approval id kind, so `plugin:` ids can resolve plugin approvals without a second Slack-local fallback layer +- Google Chat native cards preserve the manual `/approve` fallback in message text but card button + callbacks carry only opaque action tokens; approval id and decision are recovered from server-side + pending state - WhatsApp emoji approvals handle both exec and plugin prompts only when the matching top-level forwarding family is enabled and routes to WhatsApp; target-only WhatsApp forwarding stays on the shared forwarding path unless it matches the same native origin target diff --git a/extensions/googlechat/src/api.ts b/extensions/googlechat/src/api.ts index 3a6c88299fbd..75e12ad7a410 100644 --- a/extensions/googlechat/src/api.ts +++ b/extensions/googlechat/src/api.ts @@ -5,8 +5,9 @@ import { parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime"; 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 { GoogleChatReaction } from "./types.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"; @@ -139,13 +140,25 @@ export async function sendGoogleChatMessage(params: { space: string; text?: string; thread?: string; + cardsV2?: GoogleChatCardV2[]; attachments?: Array<{ attachmentUploadToken: string; contentName?: string }>; }): Promise<{ messageName?: string } | null> { - const { account, space, text, thread, attachments } = params; + 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 = {}; if (text) { body.text = text; } + if (cardsV2 && cardsV2.length > 0) { + body.cardsV2 = cardsV2; + } if (thread) { body.thread = { name: thread }; } @@ -172,13 +185,28 @@ export async function sendGoogleChatMessage(params: { export async function updateGoogleChatMessage(params: { account: ResolvedGoogleChatAccount; messageName: string; - text: string; + text?: string; + cardsV2?: GoogleChatCardV2[]; }): Promise<{ messageName?: string }> { - const { account, messageName, text } = params; - const url = `${CHAT_API_BASE}/${messageName}?updateMask=text`; + 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 = {}; + 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({ text }), + body: JSON.stringify(body), }); return { messageName: result.name }; } diff --git a/extensions/googlechat/src/approval-auth.ts b/extensions/googlechat/src/approval-auth.ts index 2876f7913a29..b89f4161821b 100644 --- a/extensions/googlechat/src/approval-auth.ts +++ b/extensions/googlechat/src/approval-auth.ts @@ -7,7 +7,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer import { resolveGoogleChatAccount } from "./accounts.js"; import { isGoogleChatUserTarget, normalizeGoogleChatTarget } from "./targets.js"; -function normalizeGoogleChatApproverId(value: string | number): string | undefined { +export function normalizeGoogleChatApproverId(value: string | number): string | undefined { const normalized = normalizeGoogleChatTarget(String(value)); if (!normalized || !isGoogleChatUserTarget(normalized)) { return undefined; @@ -19,15 +19,20 @@ function normalizeGoogleChatApproverId(value: string | number): string | undefin return `users/${suffix}`; } +export function getGoogleChatApprovalApprovers(params: { + cfg: Parameters[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: ({ cfg, accountId }) => { - const account = resolveGoogleChatAccount({ cfg, accountId }).config; - return resolveApprovalApprovers({ - allowFrom: account.dm?.allowFrom, - defaultTo: account.defaultTo, - normalizeApprover: normalizeGoogleChatApproverId, - }); - }, + resolveApprovers: getGoogleChatApprovalApprovers, normalizeSenderId: (value) => normalizeGoogleChatApproverId(value), }); diff --git a/extensions/googlechat/src/approval-card-actions.test.ts b/extensions/googlechat/src/approval-card-actions.test.ts new file mode 100644 index 000000000000..3162750fdffc --- /dev/null +++ b/extensions/googlechat/src/approval-card-actions.test.ts @@ -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); + }); +}); diff --git a/extensions/googlechat/src/approval-card-actions.ts b/extensions/googlechat/src/approval-card-actions.ts new file mode 100644 index 000000000000..bfca82f87ee3 --- /dev/null +++ b/extensions/googlechat/src/approval-card-actions.ts @@ -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(); +const approvalCardResolvingTokens = new Set(); + +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 { + const params: Record = {}; + 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(); +} diff --git a/extensions/googlechat/src/approval-card-click.test.ts b/extensions/googlechat/src/approval-card-click.test.ts new file mode 100644 index 000000000000..7cbe85c8b7f9 --- /dev/null +++ b/extensions/googlechat/src/approval-card-click.test.ts @@ -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); + }); +}); diff --git a/extensions/googlechat/src/approval-card-click.ts b/extensions/googlechat/src/approval-card-click.ts new file mode 100644 index 000000000000..214fc4c24f44 --- /dev/null +++ b/extensions/googlechat/src/approval-card-click.ts @@ -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 { + 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; +} diff --git a/extensions/googlechat/src/approval-handler.runtime.test.ts b/extensions/googlechat/src/approval-handler.runtime.test.ts new file mode 100644 index 000000000000..e80e27f50f58 --- /dev/null +++ b/extensions/googlechat/src/approval-handler.runtime.test.ts @@ -0,0 +1,388 @@ +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("./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(): { + promise: Promise; + reject: (reason?: unknown) => void; + resolve: (value: T) => void; +} { + let resolve: (value: T) => void = () => {}; + let reject: (reason?: unknown) => void = () => {}; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, reject, resolve }; +} + +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("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", + ); + }); +}); diff --git a/extensions/googlechat/src/approval-handler.runtime.ts b/extensions/googlechat/src/approval-handler.runtime.ts new file mode 100644 index 000000000000..39a36782f04a --- /dev/null +++ b/extensions/googlechat/src/approval-handler.runtime.ts @@ -0,0 +1,413 @@ +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 { resolveGoogleChatAccount, type ResolvedGoogleChatAccount } from "./accounts.js"; +import { sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js"; +import { + buildGoogleChatApprovalActionParameters, + createGoogleChatApprovalToken, + GOOGLECHAT_APPROVAL_ACTION, + registerGoogleChatApprovalCardBinding, + registerGoogleChatManualApprovalFollowupSuppression, + unregisterGoogleChatManualApprovalFollowupSuppression, + unregisterGoogleChatApprovalCardBindings, +} from "./approval-card-actions.js"; +import { + isGoogleChatNativeApprovalClientEnabled, + shouldHandleGoogleChatNativeApprovalRequest, +} from "./approval-native.js"; +import { resolveGoogleChatOutboundSpace } from "./targets.js"; +import type { GoogleChatCardV2 } from "./types.js"; + +const log = createSubsystemLogger("googlechat/approvals"); +const GOOGLECHAT_APPROVAL_CARD_ID = "openclaw-approval"; +const MAX_TEXT_PARAGRAPH_CHARS = 1800; + +type GoogleChatApprovalHandlerContext = { + account?: ResolvedGoogleChatAccount; +}; + +type GoogleChatApprovalActionToken = { + token: string; + decision: ExecApprovalDecision; +}; + +type GoogleChatPendingDelivery = { + approvalId: string; + approvalKind: "exec" | "plugin"; + expiresAtMs: number; + cardsV2: GoogleChatCardV2[]; + actionTokens: GoogleChatApprovalActionToken[]; + allowedDecisions: readonly ExecApprovalDecision[]; +}; + +type PreparedGoogleChatTarget = { + to: string; + threadName?: string; +}; + +type GoogleChatPendingEntry = { + accountId: string; + spaceName: string; + messageName: string; + threadName?: string; + actionTokens: GoogleChatApprovalActionToken[]; +}; + +type GoogleChatFinalDelivery = { + cardsV2: GoogleChatCardV2[]; +}; + +function resolveHandlerAccount( + params: ChannelApprovalCapabilityHandlerContext, +): ResolvedGoogleChatAccount | null { + const context = params.context as GoogleChatApprovalHandlerContext | undefined; + const account = + context?.account ?? + resolveGoogleChatAccount({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (!account.enabled || account.credentialSource === "none") { + return null; + } + return account; +} + +function escapeGoogleChatText(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">"); +} + +function truncateText(text: string, maxChars = MAX_TEXT_PARAGRAPH_CHARS): string { + return text.length <= maxChars ? text : `${text.slice(0, maxChars - 3)}...`; +} + +function buildMetadataText(metadata: readonly { label: string; value: string }[]): string { + return metadata + .map( + (item) => `${escapeGoogleChatText(item.label)}: ${escapeGoogleChatText(item.value)}`, + ) + .join("
"); +} + +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( + `${escapeGoogleChatText(view.title)}${ + view.description ? `
${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[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>; + 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)}`); + }, + }, +}); diff --git a/extensions/googlechat/src/approval-native.test.ts b/extensions/googlechat/src/approval-native.test.ts new file mode 100644 index 000000000000..9e7008ea7053 --- /dev/null +++ b/extensions/googlechat/src/approval-native.test.ts @@ -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); + }); +}); diff --git a/extensions/googlechat/src/approval-native.ts b/extensions/googlechat/src/approval-native.ts new file mode 100644 index 000000000000..97b6dede83a1 --- /dev/null +++ b/extensions/googlechat/src/approval-native.ts @@ -0,0 +1,246 @@ +import { + createApproverRestrictedNativeApprovalCapability, + splitChannelApprovalCapability, +} 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["shouldSuppressForwardingFallback"] + > +>[0]["target"]; + +const DEFAULT_APPROVAL_FORWARDING_MODE = "session"; + +function isGoogleChatAccountConfigured(params: { + cfg: Parameters[0]["cfg"]; + accountId?: string | null; +}): boolean { + const account = resolveGoogleChatAccount(params); + return account.enabled && account.credentialSource !== "none"; +} + +function hasGoogleChatWebhookApprovalAuthConfig(params: { + cfg: Parameters[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[0]["cfg"]; + accountId?: string | null; +}): boolean { + return isGoogleChatAccountConfigured(params) && hasGoogleChatWebhookApprovalAuthConfig(params); +} + +function normalizeGoogleChatForwardTarget( + target: Pick, +): 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[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[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, + }), + }); + +export const googleChatNativeApprovalAdapter = splitChannelApprovalCapability( + googleChatApprovalCapability, +); diff --git a/extensions/googlechat/src/channel-config.test.ts b/extensions/googlechat/src/channel-config.test.ts index 256bda52b705..de4ccf873d2b 100644 --- a/extensions/googlechat/src/channel-config.test.ts +++ b/extensions/googlechat/src/channel-config.test.ts @@ -1,10 +1,20 @@ // 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 { describe, expect, it } from "vitest"; +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); @@ -13,6 +23,10 @@ describe("googlechatPlugin config adapter", () => { ); }); + 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: { @@ -46,4 +60,115 @@ describe("googlechatPlugin config adapter", () => { "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); + }); }); diff --git a/extensions/googlechat/src/channel.adapters.ts b/extensions/googlechat/src/channel.adapters.ts index 07905c00891d..e2aaefcb72bb 100644 --- a/extensions/googlechat/src/channel.adapters.ts +++ b/extensions/googlechat/src/channel.adapters.ts @@ -21,7 +21,9 @@ import { } 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 { shouldSuppressGoogleChatManualExecApprovalFollowupPayload } from "./approval-card-actions.js"; import { formatGoogleChatAllowFromEntry } from "./channel-base.js"; import { type ResolvedGoogleChatAccount, @@ -195,6 +197,8 @@ export const googlechatOutboundAdapter = { chunkerMode: "markdown" as const, textChunkLimit: 4000, sanitizeText: ({ text }: { text: string }) => sanitizeForPlainText(text), + normalizePayload: ({ payload }: { payload: ReplyPayload }) => + shouldSuppressGoogleChatManualExecApprovalFollowupPayload(payload) ? null : payload, resolveTarget: ({ to }: { to?: string }) => { const trimmed = normalizeOptionalString(to) ?? ""; diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 853e24072af8..1b2667ebad8c 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -8,7 +8,10 @@ import { createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; import { extractToolSend } from "openclaw/plugin-sdk/tool-send"; -import { googleChatApprovalAuth } from "./approval-auth.js"; +import { + googleChatApprovalCapability, + shouldSuppressLocalGoogleChatExecApprovalPrompt, +} from "./approval-native.js"; import { createGoogleChatPluginBase, GOOGLECHAT_CHANNEL_ID } from "./channel-base.js"; import { googlechatDirectoryAdapter, @@ -79,7 +82,7 @@ export const googlechatPlugin = createChatChannelPlugin({ ...createGoogleChatPluginBase({ configSchema: buildChannelConfigSchema(GoogleChatConfigSchema), }), - approvalCapability: googleChatApprovalAuth, + approvalCapability: googleChatApprovalCapability, secrets: { secretTargetRegistryEntries, collectRuntimeConfigAssignments, @@ -195,5 +198,17 @@ export const googlechatPlugin = createChatChannelPlugin({ }, security: googlechatSecurityAdapter, threading: googlechatThreadingAdapter, - outbound: googlechatOutboundAdapter, + outbound: { + ...googlechatOutboundAdapter, + base: { + ...googlechatOutboundAdapter.base, + shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload, hint }) => + shouldSuppressLocalGoogleChatExecApprovalPrompt({ + cfg, + accountId, + payload, + hint, + }), + }, + }, }); diff --git a/extensions/googlechat/src/gateway.ts b/extensions/googlechat/src/gateway.ts index 59ac482af7a2..41fbe49fc73f 100644 --- a/extensions/googlechat/src/gateway.ts +++ b/extensions/googlechat/src/gateway.ts @@ -1,12 +1,16 @@ // 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( @@ -19,6 +23,7 @@ export async function startGoogleChatGatewayAccount(ctx: { cfg: OpenClawConfig; runtime: GoogleChatRuntimeEnv; abortSignal: AbortSignal; + channelRuntime?: ChannelRuntimeSurface; setStatus: (next: ChannelAccountSnapshot) => void; log?: { info?: (message: string) => void; @@ -39,6 +44,21 @@ export async function startGoogleChatGatewayAccount(ctx: { audienceType: account.config.audienceType, audience: account.config.audience, }); + 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, + }); + } await runPassiveAccountLifecycle({ abortSignal: ctx.abortSignal, start: async () => diff --git a/extensions/googlechat/src/monitor-reply-delivery.ts b/extensions/googlechat/src/monitor-reply-delivery.ts index a5162c06aabd..47daacde101d 100644 --- a/extensions/googlechat/src/monitor-reply-delivery.ts +++ b/extensions/googlechat/src/monitor-reply-delivery.ts @@ -14,7 +14,12 @@ import { import type { GoogleChatCoreRuntime, GoogleChatRuntimeEnv } from "./monitor-types.js"; export async function deliverGoogleChatReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; + payload: { + text?: string; + mediaUrls?: string[]; + mediaUrl?: string; + replyToId?: string; + }; account: ResolvedGoogleChatAccount; spaceId: string; runtime: GoogleChatRuntimeEnv; diff --git a/extensions/googlechat/src/monitor-webhook.test.ts b/extensions/googlechat/src/monitor-webhook.test.ts index 2f8631afc2a4..410eb0797dd8 100644 --- a/extensions/googlechat/src/monitor-webhook.test.ts +++ b/extensions/googlechat/src/monitor-webhook.test.ts @@ -296,6 +296,84 @@ describe("googlechat monitor webhook", () => { ); 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 () => { @@ -433,6 +511,8 @@ describe("googlechat monitor webhook", () => { 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 () => { @@ -499,6 +579,8 @@ describe("googlechat monitor webhook", () => { 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 () => { diff --git a/extensions/googlechat/src/monitor-webhook.ts b/extensions/googlechat/src/monitor-webhook.ts index bcc97a2a943d..9ae9ff11516c 100644 --- a/extensions/googlechat/src/monitor-webhook.ts +++ b/extensions/googlechat/src/monitor-webhook.ts @@ -15,6 +15,8 @@ import { import { verifyGoogleChatRequest } from "./auth.js"; import type { WebhookTarget } from "./monitor-types.js"; import type { + GoogleChatAction, + GoogleChatActionParameter, GoogleChatEvent, GoogleChatMessage, GoogleChatSpace, @@ -42,6 +44,18 @@ type ParsedGoogleChatInboundPayload = | { ok: false }; type ParsedGoogleChatInboundSuccess = Extract; +function recordParamsToActionParameters( + params?: Record, +): 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, @@ -57,15 +71,32 @@ function parseGoogleChatInboundPayload( // Transform Google Workspace Add-on format to standard Chat API format. const rawObj = raw as { - commonEventObject?: { hostApp?: string }; + commonEventObject?: { + hostApp?: string; + invokedFunction?: string; + parameters?: Record; + }; 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; @@ -76,10 +107,30 @@ function parseGoogleChatInboundPayload( user: chat.user, eventTime: chat.eventTime, }; - addOnBearerToken = - typeof rawObj.authorizationEventObject?.systemIdToken === "string" - ? rawObj.authorizationEventObject.systemIdToken.trim() - : ""; + } 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; @@ -102,6 +153,12 @@ function parseGoogleChatInboundPayload( 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 }; diff --git a/extensions/googlechat/src/monitor.test.ts b/extensions/googlechat/src/monitor.test.ts index 8e96b391d86f..a3258f206f81 100644 --- a/extensions/googlechat/src/monitor.test.ts +++ b/extensions/googlechat/src/monitor.test.ts @@ -160,6 +160,75 @@ describe("googlechat monitor bot loop protection", () => { }); 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); diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 40f1cd1260c5..979ef603f6e8 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -12,6 +12,7 @@ import { } 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"; @@ -121,6 +122,10 @@ function shouldSuppressGoogleChatBotLoop(params: { 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; } diff --git a/extensions/googlechat/src/targets.test.ts b/extensions/googlechat/src/targets.test.ts index 074c5538beb9..3150e30a7420 100644 --- a/extensions/googlechat/src/targets.test.ts +++ b/extensions/googlechat/src/targets.test.ts @@ -1,7 +1,11 @@ // Googlechat tests cover targets plugin behavior. import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; -import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js"; +import { downloadGoogleChatMedia, sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js"; +import { + clearGoogleChatApprovalCardBindingsForTest, + registerGoogleChatManualApprovalFollowupSuppression, +} from "./approval-card-actions.js"; import { resolveGoogleChatGroupRequireMention } from "./group-policy.js"; import { isGoogleChatSpaceTarget, @@ -171,6 +175,7 @@ describe("googlechat group policy", () => { describe("downloadGoogleChatMedia", () => { afterEach(() => { + clearGoogleChatApprovalCardBindingsForTest(); authTesting.resetGoogleChatAuthForTests(); mocks.fetchWithSsrFGuard.mockClear(); vi.unstubAllGlobals(); @@ -270,6 +275,53 @@ describe("sendGoogleChatMessage", () => { 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", @@ -291,6 +343,43 @@ describe("sendGoogleChatMessage", () => { }); }); +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) { mocks.verifyIdToken.mockResolvedValue({ getPayload: () => payload, diff --git a/extensions/googlechat/src/types.ts b/extensions/googlechat/src/types.ts index c656979ed6b8..30cafdfc744d 100644 --- a/extensions/googlechat/src/types.ts +++ b/extensions/googlechat/src/types.ts @@ -54,10 +54,21 @@ export type GoogleChatMessage = { 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; @@ -65,6 +76,15 @@ export type GoogleChatEvent = { space?: GoogleChatSpace; user?: GoogleChatUser; message?: GoogleChatMessage; + action?: GoogleChatAction; + common?: { + invokedFunction?: string; + parameters?: Record; + }; + commonEventObject?: { + invokedFunction?: string; + parameters?: Record; + }; }; export type GoogleChatReaction = { @@ -72,3 +92,48 @@ export type GoogleChatReaction = { 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 }; + +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[]; + }>; + }; +}; diff --git a/src/channels/plugins/native-approval-prompt.ts b/src/channels/plugins/native-approval-prompt.ts index 7d12847e3bc7..19d4fdd01afb 100644 --- a/src/channels/plugins/native-approval-prompt.ts +++ b/src/channels/plugins/native-approval-prompt.ts @@ -13,6 +13,7 @@ const NATIVE_APPROVAL_PROMPT_RUNTIME_CAPABILITY_NORMALIZED = "nativeapprovals"; const KNOWN_NATIVE_APPROVAL_PROMPT_CHANNELS = new Set([ "discord", + "googlechat", "matrix", "qqbot", "slack",