mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 18:35:48 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
3
openclaw/extensions/googlechat/api.ts
Normal file
3
openclaw/extensions/googlechat/api.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { googlechatPlugin } from "./src/channel.js";
|
||||
export * from "./src/setup-core.js";
|
||||
export * from "./src/setup-surface.js";
|
||||
1
openclaw/extensions/googlechat/channel-config-api.ts
Normal file
1
openclaw/extensions/googlechat/channel-config-api.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { GoogleChatChannelConfigSchema } from "./src/config-schema.js";
|
||||
4
openclaw/extensions/googlechat/contract-api.ts
Normal file
4
openclaw/extensions/googlechat/contract-api.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
20
openclaw/extensions/googlechat/index.ts
Normal file
20
openclaw/extensions/googlechat/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelEntry({
|
||||
id: "googlechat",
|
||||
name: "Google Chat",
|
||||
description: "OpenClaw Google Chat channel plugin",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./api.js",
|
||||
exportName: "googlechatPlugin",
|
||||
},
|
||||
secrets: {
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-api.js",
|
||||
exportName: "setGoogleChatRuntime",
|
||||
},
|
||||
});
|
||||
12
openclaw/extensions/googlechat/openclaw.plugin.json
Normal file
12
openclaw/extensions/googlechat/openclaw.plugin.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"id": "googlechat",
|
||||
"channels": ["googlechat"],
|
||||
"channelEnvVars": {
|
||||
"googlechat": ["GOOGLE_CHAT_SERVICE_ACCOUNT", "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
49
openclaw/extensions/googlechat/package.json
Normal file
49
openclaw/extensions/googlechat/package.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"name": "@openclaw/googlechat",
|
||||
"version": "2026.4.20",
|
||||
"private": true,
|
||||
"description": "OpenClaw Google Chat channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"google-auth-library": "^10.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.4.20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "googlechat",
|
||||
"label": "Google Chat",
|
||||
"selectionLabel": "Google Chat (Chat API)",
|
||||
"detailLabel": "Google Chat",
|
||||
"docsPath": "/channels/googlechat",
|
||||
"docsLabel": "googlechat",
|
||||
"blurb": "Google Workspace Chat app with HTTP webhook.",
|
||||
"aliases": [
|
||||
"gchat",
|
||||
"google-chat"
|
||||
],
|
||||
"order": 55,
|
||||
"systemImage": "message.badge",
|
||||
"markdownCapable": true
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/googlechat",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
63
openclaw/extensions/googlechat/runtime-api.ts
Normal file
63
openclaw/extensions/googlechat/runtime-api.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// Private runtime barrel for the bundled Google Chat extension.
|
||||
// Keep this barrel thin and avoid broad plugin-sdk surfaces during bootstrap.
|
||||
|
||||
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
export {
|
||||
createActionGate,
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk/channel-actions";
|
||||
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
|
||||
export type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
ChannelStatusIssue,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
export { missingTargetError } from "openclaw/plugin-sdk/channel-feedback";
|
||||
export {
|
||||
createAccountStatusSink,
|
||||
runPassiveAccountLifecycle,
|
||||
} from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
export {
|
||||
evaluateGroupRouteAccessForPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveSenderScopedGroupPolicy,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
export {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export { fetchRemoteMedia, resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
|
||||
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
|
||||
export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
export { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
export {
|
||||
GoogleChatConfigSchema,
|
||||
type GoogleChatAccountConfig,
|
||||
type GoogleChatConfig,
|
||||
} from "openclaw/plugin-sdk/googlechat-runtime-shared";
|
||||
export { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||
export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
|
||||
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
||||
export { resolveWebhookPath } from "openclaw/plugin-sdk/webhook-path";
|
||||
export {
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
resolveWebhookTargetWithAuthOrReject,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
} from "openclaw/plugin-sdk/webhook-targets";
|
||||
export {
|
||||
createWebhookInFlightLimiter,
|
||||
readJsonWebhookBodyOrReject,
|
||||
type WebhookInFlightLimiter,
|
||||
} from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
export { setGoogleChatRuntime } from "./src/runtime.js";
|
||||
5
openclaw/extensions/googlechat/secret-contract-api.ts
Normal file
5
openclaw/extensions/googlechat/secret-contract-api.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export {
|
||||
channelSecrets,
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
13
openclaw/extensions/googlechat/setup-entry.ts
Normal file
13
openclaw/extensions/googlechat/setup-entry.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./api.js",
|
||||
exportName: "googlechatPlugin",
|
||||
},
|
||||
secrets: {
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
});
|
||||
162
openclaw/extensions/googlechat/src/accounts.ts
Normal file
162
openclaw/extensions/googlechat/src/accounts.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import {
|
||||
createAccountListHelpers,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
type OpenClawConfig,
|
||||
resolveAccountEntry,
|
||||
resolveMergedAccountConfig,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { isSecretRef } from "openclaw/plugin-sdk/secret-input";
|
||||
import { z } from "zod";
|
||||
import type { GoogleChatAccountConfig } from "./types.config.js";
|
||||
|
||||
export type GoogleChatCredentialSource = "file" | "inline" | "env" | "none";
|
||||
|
||||
export type ResolvedGoogleChatAccount = {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
config: GoogleChatAccountConfig;
|
||||
credentialSource: GoogleChatCredentialSource;
|
||||
credentials?: Record<string, unknown>;
|
||||
credentialsFile?: string;
|
||||
};
|
||||
|
||||
const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
|
||||
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
|
||||
const JsonRecordSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
const {
|
||||
listAccountIds: listGoogleChatAccountIds,
|
||||
resolveDefaultAccountId: resolveDefaultGoogleChatAccountId,
|
||||
} = createAccountListHelpers("googlechat");
|
||||
export { listGoogleChatAccountIds, resolveDefaultGoogleChatAccountId };
|
||||
|
||||
function mergeGoogleChatAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): GoogleChatAccountConfig {
|
||||
const raw = cfg.channels?.["googlechat"] ?? {};
|
||||
const base = resolveMergedAccountConfig<GoogleChatAccountConfig>({
|
||||
channelConfig: raw as GoogleChatAccountConfig,
|
||||
accounts: raw.accounts as Record<string, Partial<GoogleChatAccountConfig>> | undefined,
|
||||
accountId,
|
||||
omitKeys: ["defaultAccount"],
|
||||
});
|
||||
const defaultAccountConfig = resolveAccountEntry(raw.accounts, DEFAULT_ACCOUNT_ID) ?? {};
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return base;
|
||||
}
|
||||
const {
|
||||
enabled: _ignoredEnabled,
|
||||
dangerouslyAllowNameMatching: _ignoredDangerouslyAllowNameMatching,
|
||||
serviceAccount: _ignoredServiceAccount,
|
||||
serviceAccountRef: _ignoredServiceAccountRef,
|
||||
serviceAccountFile: _ignoredServiceAccountFile,
|
||||
...defaultAccountShared
|
||||
} = defaultAccountConfig;
|
||||
// In multi-account setups, allow accounts.default to provide shared defaults
|
||||
// (for example webhook/audience fields) while preserving top-level and account overrides.
|
||||
return { ...defaultAccountShared, ...base } as GoogleChatAccountConfig;
|
||||
}
|
||||
|
||||
function parseServiceAccount(value: unknown): Record<string, unknown> | null {
|
||||
if (isSecretRef(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return safeParseJsonWithSchema(JsonRecordSchema, trimmed);
|
||||
}
|
||||
|
||||
return safeParseWithSchema(JsonRecordSchema, value);
|
||||
}
|
||||
|
||||
function resolveCredentialsFromConfig(params: {
|
||||
accountId: string;
|
||||
account: GoogleChatAccountConfig;
|
||||
}): {
|
||||
credentials?: Record<string, unknown>;
|
||||
credentialsFile?: string;
|
||||
source: GoogleChatCredentialSource;
|
||||
} {
|
||||
const { account, accountId } = params;
|
||||
const inline = parseServiceAccount(account.serviceAccount);
|
||||
if (inline) {
|
||||
return { credentials: inline, source: "inline" };
|
||||
}
|
||||
|
||||
if (isSecretRef(account.serviceAccount)) {
|
||||
throw new Error(
|
||||
`channels.googlechat.accounts.${accountId}.serviceAccount: unresolved SecretRef "${account.serviceAccount.source}:${account.serviceAccount.provider}:${account.serviceAccount.id}". Resolve this command against an active gateway runtime snapshot before reading it.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (isSecretRef(account.serviceAccountRef)) {
|
||||
throw new Error(
|
||||
`channels.googlechat.accounts.${accountId}.serviceAccount: unresolved SecretRef "${account.serviceAccountRef.source}:${account.serviceAccountRef.provider}:${account.serviceAccountRef.id}". Resolve this command against an active gateway runtime snapshot before reading it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const file = normalizeOptionalString(account.serviceAccountFile);
|
||||
if (file) {
|
||||
return { credentialsFile: file, source: "file" };
|
||||
}
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
const envJson = process.env[ENV_SERVICE_ACCOUNT];
|
||||
const envInline = parseServiceAccount(envJson);
|
||||
if (envInline) {
|
||||
return { credentials: envInline, source: "env" };
|
||||
}
|
||||
const envFile = normalizeOptionalString(process.env[ENV_SERVICE_ACCOUNT_FILE]);
|
||||
if (envFile) {
|
||||
return { credentialsFile: envFile, source: "env" };
|
||||
}
|
||||
}
|
||||
|
||||
return { source: "none" };
|
||||
}
|
||||
|
||||
export function resolveGoogleChatAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedGoogleChatAccount {
|
||||
const accountId = normalizeAccountId(
|
||||
params.accountId ?? params.cfg.channels?.["googlechat"]?.defaultAccount,
|
||||
);
|
||||
const baseEnabled = params.cfg.channels?.["googlechat"]?.enabled !== false;
|
||||
const merged = mergeGoogleChatAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const credentials = resolveCredentialsFromConfig({ accountId, account: merged });
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: normalizeOptionalString(merged.name),
|
||||
enabled,
|
||||
config: merged,
|
||||
credentialSource: credentials.source,
|
||||
credentials: credentials.credentials,
|
||||
credentialsFile: credentials.credentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledGoogleChatAccounts(cfg: OpenClawConfig): ResolvedGoogleChatAccount[] {
|
||||
return listGoogleChatAccountIds(cfg)
|
||||
.map((accountId) => resolveGoogleChatAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
265
openclaw/extensions/googlechat/src/actions.test.ts
Normal file
265
openclaw/extensions/googlechat/src/actions.test.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import path from "node:path";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listEnabledGoogleChatAccounts = vi.hoisted(() => vi.fn());
|
||||
const resolveGoogleChatAccount = vi.hoisted(() => vi.fn());
|
||||
const createGoogleChatReaction = vi.hoisted(() => vi.fn());
|
||||
const deleteGoogleChatReaction = vi.hoisted(() => vi.fn());
|
||||
const listGoogleChatReactions = vi.hoisted(() => vi.fn());
|
||||
const sendGoogleChatMessage = vi.hoisted(() => vi.fn());
|
||||
const uploadGoogleChatAttachment = vi.hoisted(() => vi.fn());
|
||||
const resolveGoogleChatOutboundSpace = vi.hoisted(() => vi.fn());
|
||||
const getGoogleChatRuntime = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
listEnabledGoogleChatAccounts,
|
||||
resolveGoogleChatAccount,
|
||||
}));
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
createGoogleChatReaction,
|
||||
deleteGoogleChatReaction,
|
||||
listGoogleChatReactions,
|
||||
sendGoogleChatMessage,
|
||||
uploadGoogleChatAttachment,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getGoogleChatRuntime,
|
||||
}));
|
||||
|
||||
vi.mock("./targets.js", () => ({
|
||||
resolveGoogleChatOutboundSpace,
|
||||
}));
|
||||
|
||||
let googlechatMessageActions: typeof import("./actions.js").googlechatMessageActions;
|
||||
|
||||
describe("googlechat message actions", () => {
|
||||
beforeAll(async () => {
|
||||
({ googlechatMessageActions } = await import("./actions.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("describes send and reaction actions only when enabled accounts exist", async () => {
|
||||
listEnabledGoogleChatAccounts.mockReturnValueOnce([]);
|
||||
expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toBeNull();
|
||||
|
||||
listEnabledGoogleChatAccounts.mockReturnValueOnce([
|
||||
{
|
||||
enabled: true,
|
||||
credentialSource: "service-account",
|
||||
config: { actions: { reactions: true } },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toEqual({
|
||||
actions: ["send", "upload-file", "react", "reactions"],
|
||||
});
|
||||
});
|
||||
|
||||
it("honors account-scoped reaction gates during discovery", () => {
|
||||
resolveGoogleChatAccount.mockImplementation(({ accountId }: { accountId?: string | null }) => ({
|
||||
enabled: true,
|
||||
credentialSource: "service-account",
|
||||
config: {
|
||||
actions: { reactions: accountId === "work" },
|
||||
},
|
||||
}));
|
||||
|
||||
expect(
|
||||
googlechatMessageActions.describeMessageTool?.({ cfg: {} as never, accountId: "default" }),
|
||||
).toEqual({
|
||||
actions: ["send", "upload-file"],
|
||||
});
|
||||
expect(
|
||||
googlechatMessageActions.describeMessageTool?.({ cfg: {} as never, accountId: "work" }),
|
||||
).toEqual({
|
||||
actions: ["send", "upload-file", "react", "reactions"],
|
||||
});
|
||||
});
|
||||
|
||||
it("sends messages with uploaded media through the resolved space", async () => {
|
||||
resolveGoogleChatAccount.mockReturnValue({
|
||||
credentialSource: "service-account",
|
||||
config: { mediaMaxMb: 5 },
|
||||
});
|
||||
resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/AAA");
|
||||
getGoogleChatRuntime.mockReturnValue({
|
||||
channel: {
|
||||
media: {
|
||||
fetchRemoteMedia: vi.fn(async () => ({
|
||||
buffer: Buffer.from("remote-bytes"),
|
||||
fileName: "remote.png",
|
||||
contentType: "image/png",
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
uploadGoogleChatAttachment.mockResolvedValue({
|
||||
attachmentUploadToken: "token-1",
|
||||
});
|
||||
sendGoogleChatMessage.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
});
|
||||
|
||||
if (!googlechatMessageActions.handleAction) {
|
||||
throw new Error("Expected googlechatMessageActions.handleAction to be defined");
|
||||
}
|
||||
const result = await googlechatMessageActions.handleAction({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "spaces/AAA",
|
||||
message: "caption",
|
||||
media: "https://example.com/file.png",
|
||||
threadId: "thread-1",
|
||||
},
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
} as never);
|
||||
|
||||
expect(resolveGoogleChatOutboundSpace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: "spaces/AAA",
|
||||
}),
|
||||
);
|
||||
expect(uploadGoogleChatAttachment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/AAA",
|
||||
filename: "remote.png",
|
||||
}),
|
||||
);
|
||||
expect(sendGoogleChatMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/AAA",
|
||||
text: "caption",
|
||||
thread: "thread-1",
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
details: {
|
||||
ok: true,
|
||||
to: "spaces/AAA",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("routes upload-file through the same attachment upload path with filename override", async () => {
|
||||
resolveGoogleChatAccount.mockReturnValue({
|
||||
credentialSource: "service-account",
|
||||
config: { mediaMaxMb: 5 },
|
||||
});
|
||||
resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/BBB");
|
||||
const localRoot = "/tmp/googlechat-action-test";
|
||||
const localPath = path.join(localRoot, "local.md");
|
||||
const readFile = vi.fn(async () => Buffer.from("local-bytes"));
|
||||
getGoogleChatRuntime.mockReturnValue({
|
||||
channel: {
|
||||
media: {
|
||||
fetchRemoteMedia: vi.fn(),
|
||||
},
|
||||
},
|
||||
});
|
||||
uploadGoogleChatAttachment.mockResolvedValue({
|
||||
attachmentUploadToken: "token-2",
|
||||
});
|
||||
sendGoogleChatMessage.mockResolvedValue({
|
||||
messageName: "spaces/BBB/messages/msg-2",
|
||||
});
|
||||
|
||||
if (!googlechatMessageActions.handleAction) {
|
||||
throw new Error("Expected googlechatMessageActions.handleAction to be defined");
|
||||
}
|
||||
const result = await googlechatMessageActions.handleAction({
|
||||
action: "upload-file",
|
||||
params: {
|
||||
to: "spaces/BBB",
|
||||
path: localPath,
|
||||
message: "notes",
|
||||
filename: "renamed.txt",
|
||||
},
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
mediaLocalRoots: [localRoot],
|
||||
mediaReadFile: readFile,
|
||||
} as never);
|
||||
|
||||
expect(readFile).toHaveBeenCalledWith(localPath);
|
||||
expect(uploadGoogleChatAttachment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/BBB",
|
||||
filename: "renamed.txt",
|
||||
buffer: Buffer.from("local-bytes"),
|
||||
}),
|
||||
);
|
||||
expect(sendGoogleChatMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/BBB",
|
||||
text: "notes",
|
||||
attachments: [{ attachmentUploadToken: "token-2", contentName: "renamed.txt" }],
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
details: {
|
||||
ok: true,
|
||||
to: "spaces/BBB",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("removes only matching app reactions on react remove", async () => {
|
||||
resolveGoogleChatAccount.mockReturnValue({
|
||||
credentialSource: "service-account",
|
||||
config: { botUser: "users/app-bot" },
|
||||
});
|
||||
listGoogleChatReactions.mockResolvedValue([
|
||||
{
|
||||
name: "reactions/1",
|
||||
emoji: { unicode: "👍" },
|
||||
user: { name: "users/app" },
|
||||
},
|
||||
{
|
||||
name: "reactions/2",
|
||||
emoji: { unicode: "👍" },
|
||||
user: { name: "users/app-bot" },
|
||||
},
|
||||
{
|
||||
name: "reactions/3",
|
||||
emoji: { unicode: "👍" },
|
||||
user: { name: "users/other" },
|
||||
},
|
||||
]);
|
||||
|
||||
if (!googlechatMessageActions.handleAction) {
|
||||
throw new Error("Expected googlechatMessageActions.handleAction to be defined");
|
||||
}
|
||||
const result = await googlechatMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
messageId: "spaces/AAA/messages/msg-1",
|
||||
emoji: "👍",
|
||||
remove: true,
|
||||
},
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
} as never);
|
||||
|
||||
expect(deleteGoogleChatReaction).toHaveBeenCalledTimes(2);
|
||||
expect(deleteGoogleChatReaction).toHaveBeenNthCalledWith(1, {
|
||||
account: expect.anything(),
|
||||
reactionName: "reactions/1",
|
||||
});
|
||||
expect(deleteGoogleChatReaction).toHaveBeenNthCalledWith(2, {
|
||||
account: expect.anything(),
|
||||
reactionName: "reactions/2",
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
details: {
|
||||
ok: true,
|
||||
removed: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
227
openclaw/extensions/googlechat/src/actions.ts
Normal file
227
openclaw/extensions/googlechat/src/actions.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import {
|
||||
createActionGate,
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk/channel-actions";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
|
||||
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||
import { listEnabledGoogleChatAccounts, resolveGoogleChatAccount } from "./accounts.js";
|
||||
import {
|
||||
createGoogleChatReaction,
|
||||
deleteGoogleChatReaction,
|
||||
listGoogleChatReactions,
|
||||
sendGoogleChatMessage,
|
||||
uploadGoogleChatAttachment,
|
||||
} from "./api.js";
|
||||
import { getGoogleChatRuntime } from "./runtime.js";
|
||||
import { resolveGoogleChatOutboundSpace } from "./targets.js";
|
||||
|
||||
const providerId = "googlechat";
|
||||
|
||||
function listEnabledAccounts(cfg: OpenClawConfig) {
|
||||
return listEnabledGoogleChatAccounts(cfg).filter(
|
||||
(account) => account.enabled && account.credentialSource !== "none",
|
||||
);
|
||||
}
|
||||
|
||||
function isReactionsEnabled(accounts: Array<{ config: { actions?: unknown } }>) {
|
||||
for (const account of accounts) {
|
||||
const gate = createActionGate(account.config.actions as Record<string, boolean | undefined>);
|
||||
if (gate("reactions")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveAppUserNames(account: { config: { botUser?: string | null } }) {
|
||||
return new Set(["users/app", account.config.botUser?.trim()].filter(Boolean) as string[]);
|
||||
}
|
||||
|
||||
async function loadGoogleChatActionMedia(params: {
|
||||
mediaUrl: string;
|
||||
maxBytes: number;
|
||||
mediaAccess?: {
|
||||
localRoots?: readonly string[];
|
||||
readFile?: (filePath: string) => Promise<Buffer>;
|
||||
};
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
}) {
|
||||
const runtime = getGoogleChatRuntime();
|
||||
return /^https?:\/\//i.test(params.mediaUrl)
|
||||
? await runtime.channel.media.fetchRemoteMedia({
|
||||
url: params.mediaUrl,
|
||||
maxBytes: params.maxBytes,
|
||||
})
|
||||
: await loadOutboundMediaFromUrl(params.mediaUrl, {
|
||||
maxBytes: params.maxBytes,
|
||||
mediaAccess: params.mediaAccess,
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
mediaReadFile: params.mediaReadFile,
|
||||
});
|
||||
}
|
||||
|
||||
export const googlechatMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: ({ cfg, accountId }) => {
|
||||
const accounts = accountId
|
||||
? [resolveGoogleChatAccount({ cfg, accountId })].filter(
|
||||
(account) => account.enabled && account.credentialSource !== "none",
|
||||
)
|
||||
: listEnabledAccounts(cfg);
|
||||
if (accounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const actions = new Set<ChannelMessageActionName>([]);
|
||||
actions.add("send");
|
||||
actions.add("upload-file");
|
||||
if (isReactionsEnabled(accounts)) {
|
||||
actions.add("react");
|
||||
actions.add("reactions");
|
||||
}
|
||||
return { actions: Array.from(actions) };
|
||||
},
|
||||
extractToolSend: ({ args }) => {
|
||||
return extractToolSend(args, "sendMessage");
|
||||
},
|
||||
handleAction: async ({
|
||||
action,
|
||||
params,
|
||||
cfg,
|
||||
accountId,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
}) => {
|
||||
const account = resolveGoogleChatAccount({
|
||||
cfg: cfg,
|
||||
accountId,
|
||||
});
|
||||
if (account.credentialSource === "none") {
|
||||
throw new Error("Google Chat credentials are missing.");
|
||||
}
|
||||
|
||||
if (action === "send" || action === "upload-file") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content =
|
||||
readStringParam(params, "message", {
|
||||
required: action === "send",
|
||||
allowEmpty: true,
|
||||
}) ??
|
||||
readStringParam(params, "initialComment", {
|
||||
allowEmpty: true,
|
||||
}) ??
|
||||
"";
|
||||
const mediaUrl =
|
||||
readStringParam(params, "media", { trim: false }) ??
|
||||
readStringParam(params, "filePath", { trim: false }) ??
|
||||
readStringParam(params, "path", { trim: false });
|
||||
const threadId = readStringParam(params, "threadId") ?? readStringParam(params, "replyTo");
|
||||
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
|
||||
|
||||
if (mediaUrl) {
|
||||
const maxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||
const loaded = await loadGoogleChatActionMedia({
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
});
|
||||
const uploadFileName =
|
||||
readStringParam(params, "filename") ??
|
||||
readStringParam(params, "title") ??
|
||||
loaded.fileName ??
|
||||
"attachment";
|
||||
const upload = await uploadGoogleChatAttachment({
|
||||
account,
|
||||
space,
|
||||
filename: uploadFileName,
|
||||
buffer: loaded.buffer,
|
||||
contentType: loaded.contentType,
|
||||
});
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text: content,
|
||||
thread: threadId ?? undefined,
|
||||
attachments: upload.attachmentUploadToken
|
||||
? [
|
||||
{
|
||||
attachmentUploadToken: upload.attachmentUploadToken,
|
||||
contentName: uploadFileName,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, to: space });
|
||||
}
|
||||
|
||||
if (action === "upload-file") {
|
||||
throw new Error("upload-file requires media, filePath, or path");
|
||||
}
|
||||
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text: content,
|
||||
thread: threadId ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, to: space });
|
||||
}
|
||||
|
||||
if (action === "react") {
|
||||
const messageName = readStringParam(params, "messageId", { required: true });
|
||||
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
||||
removeErrorMessage: "Emoji is required to remove a Google Chat reaction.",
|
||||
});
|
||||
if (remove || isEmpty) {
|
||||
const reactions = await listGoogleChatReactions({ account, messageName });
|
||||
const appUsers = resolveAppUserNames(account);
|
||||
const toRemove = reactions.filter((reaction) => {
|
||||
const userName = reaction.user?.name?.trim();
|
||||
if (appUsers.size > 0 && !appUsers.has(userName ?? "")) {
|
||||
return false;
|
||||
}
|
||||
if (emoji) {
|
||||
return reaction.emoji?.unicode === emoji;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
for (const reaction of toRemove) {
|
||||
if (!reaction.name) {
|
||||
continue;
|
||||
}
|
||||
await deleteGoogleChatReaction({ account, reactionName: reaction.name });
|
||||
}
|
||||
return jsonResult({ ok: true, removed: toRemove.length });
|
||||
}
|
||||
const reaction = await createGoogleChatReaction({
|
||||
account,
|
||||
messageName,
|
||||
emoji,
|
||||
});
|
||||
return jsonResult({ ok: true, reaction });
|
||||
}
|
||||
|
||||
if (action === "reactions") {
|
||||
const messageName = readStringParam(params, "messageId", { required: true });
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
const reactions = await listGoogleChatReactions({
|
||||
account,
|
||||
messageName,
|
||||
limit: limit ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, reactions });
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||
},
|
||||
};
|
||||
322
openclaw/extensions/googlechat/src/api.ts
Normal file
322
openclaw/extensions/googlechat/src/api.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
import crypto from "node:crypto";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { getGoogleChatAccessToken } from "./auth.js";
|
||||
import type { GoogleChatReaction } from "./types.js";
|
||||
|
||||
const CHAT_API_BASE = "https://chat.googleapis.com/v1";
|
||||
const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1";
|
||||
|
||||
const headersToObject = (headers?: HeadersInit): Record<string, string> =>
|
||||
headers instanceof Headers
|
||||
? Object.fromEntries(headers.entries())
|
||||
: Array.isArray(headers)
|
||||
? Object.fromEntries(headers)
|
||||
: headers || {};
|
||||
|
||||
async function withGoogleChatResponse<T>(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
url: string;
|
||||
init?: RequestInit;
|
||||
auditContext: string;
|
||||
errorPrefix?: string;
|
||||
handleResponse: (response: Response) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
const {
|
||||
account,
|
||||
url,
|
||||
init,
|
||||
auditContext,
|
||||
errorPrefix = "Google Chat API",
|
||||
handleResponse,
|
||||
} = params;
|
||||
const token = await getGoogleChatAccessToken(account);
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: {
|
||||
...init,
|
||||
headers: {
|
||||
...headersToObject(init?.headers),
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
auditContext,
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`${errorPrefix} ${response.status}: ${text || response.statusText}`);
|
||||
}
|
||||
return await handleResponse(response);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson<T>(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
): Promise<T> {
|
||||
return await withGoogleChatResponse({
|
||||
account,
|
||||
url,
|
||||
init: {
|
||||
...init,
|
||||
headers: {
|
||||
...headersToObject(init.headers),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
auditContext: "googlechat.api.json",
|
||||
handleResponse: async (response) => (await response.json()) as T,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchOk(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
): Promise<void> {
|
||||
await withGoogleChatResponse({
|
||||
account,
|
||||
url,
|
||||
init,
|
||||
auditContext: "googlechat.api.ok",
|
||||
handleResponse: async () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchBuffer(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
options?: { maxBytes?: number },
|
||||
): Promise<{ buffer: Buffer; contentType?: string }> {
|
||||
return await withGoogleChatResponse({
|
||||
account,
|
||||
url,
|
||||
init,
|
||||
auditContext: "googlechat.api.buffer",
|
||||
handleResponse: async (res) => {
|
||||
const maxBytes = options?.maxBytes;
|
||||
const lengthHeader = res.headers.get("content-length");
|
||||
if (maxBytes && lengthHeader) {
|
||||
const length = Number(lengthHeader);
|
||||
if (Number.isFinite(length) && length > maxBytes) {
|
||||
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
||||
}
|
||||
}
|
||||
if (!maxBytes || !res.body) {
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const contentType = res.headers.get("content-type") ?? undefined;
|
||||
return { buffer, contentType };
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
total += value.length;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel();
|
||||
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
const buffer = Buffer.concat(chunks, total);
|
||||
const contentType = res.headers.get("content-type") ?? undefined;
|
||||
return { buffer, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendGoogleChatMessage(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
space: string;
|
||||
text?: string;
|
||||
thread?: string;
|
||||
attachments?: Array<{ attachmentUploadToken: string; contentName?: string }>;
|
||||
}): Promise<{ messageName?: string } | null> {
|
||||
const { account, space, text, thread, attachments } = params;
|
||||
const body: Record<string, unknown> = {};
|
||||
if (text) {
|
||||
body.text = text;
|
||||
}
|
||||
if (thread) {
|
||||
body.thread = { name: thread };
|
||||
}
|
||||
if (attachments && attachments.length > 0) {
|
||||
body.attachment = attachments.map((item) =>
|
||||
Object.assign(
|
||||
{ attachmentDataRef: { attachmentUploadToken: item.attachmentUploadToken } },
|
||||
item.contentName ? { contentName: item.contentName } : {},
|
||||
),
|
||||
);
|
||||
}
|
||||
const urlObj = new URL(`${CHAT_API_BASE}/${space}/messages`);
|
||||
if (thread) {
|
||||
urlObj.searchParams.set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD");
|
||||
}
|
||||
const url = urlObj.toString();
|
||||
const result = await fetchJson<{ name?: string }>(account, url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return result ? { messageName: result.name } : null;
|
||||
}
|
||||
|
||||
export async function updateGoogleChatMessage(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
messageName: string;
|
||||
text: string;
|
||||
}): Promise<{ messageName?: string }> {
|
||||
const { account, messageName, text } = params;
|
||||
const url = `${CHAT_API_BASE}/${messageName}?updateMask=text`;
|
||||
const result = await fetchJson<{ name?: string }>(account, url, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
return { messageName: result.name };
|
||||
}
|
||||
|
||||
export async function deleteGoogleChatMessage(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
messageName: string;
|
||||
}): Promise<void> {
|
||||
const { account, messageName } = params;
|
||||
const url = `${CHAT_API_BASE}/${messageName}`;
|
||||
await fetchOk(account, url, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function uploadGoogleChatAttachment(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
space: string;
|
||||
filename: string;
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
}): Promise<{ attachmentUploadToken?: string }> {
|
||||
const { account, space, filename, buffer, contentType } = params;
|
||||
const boundary = `openclaw-${crypto.randomUUID()}`;
|
||||
const metadata = JSON.stringify({ filename });
|
||||
const header = `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n`;
|
||||
const mediaHeader = `--${boundary}\r\nContent-Type: ${contentType ?? "application/octet-stream"}\r\n\r\n`;
|
||||
const footer = `\r\n--${boundary}--\r\n`;
|
||||
const body = Buffer.concat([
|
||||
Buffer.from(header, "utf8"),
|
||||
Buffer.from(mediaHeader, "utf8"),
|
||||
buffer,
|
||||
Buffer.from(footer, "utf8"),
|
||||
]);
|
||||
|
||||
const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`;
|
||||
const payload = await withGoogleChatResponse<{
|
||||
attachmentDataRef?: { attachmentUploadToken?: string };
|
||||
}>({
|
||||
account,
|
||||
url,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": `multipart/related; boundary=${boundary}`,
|
||||
},
|
||||
body,
|
||||
},
|
||||
auditContext: "googlechat.upload",
|
||||
errorPrefix: "Google Chat upload",
|
||||
handleResponse: async (response) =>
|
||||
(await response.json()) as {
|
||||
attachmentDataRef?: { attachmentUploadToken?: string };
|
||||
},
|
||||
});
|
||||
return {
|
||||
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadGoogleChatMedia(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
resourceName: string;
|
||||
maxBytes?: number;
|
||||
}): Promise<{ buffer: Buffer; contentType?: string }> {
|
||||
const { account, resourceName, maxBytes } = params;
|
||||
const url = `${CHAT_API_BASE}/media/${resourceName}?alt=media`;
|
||||
return await fetchBuffer(account, url, undefined, { maxBytes });
|
||||
}
|
||||
|
||||
export async function createGoogleChatReaction(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
messageName: string;
|
||||
emoji: string;
|
||||
}): Promise<GoogleChatReaction> {
|
||||
const { account, messageName, emoji } = params;
|
||||
const url = `${CHAT_API_BASE}/${messageName}/reactions`;
|
||||
return await fetchJson<GoogleChatReaction>(account, url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ emoji: { unicode: emoji } }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listGoogleChatReactions(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
messageName: string;
|
||||
limit?: number;
|
||||
}): Promise<GoogleChatReaction[]> {
|
||||
const { account, messageName, limit } = params;
|
||||
const url = new URL(`${CHAT_API_BASE}/${messageName}/reactions`);
|
||||
if (limit && limit > 0) {
|
||||
url.searchParams.set("pageSize", String(limit));
|
||||
}
|
||||
const result = await fetchJson<{ reactions?: GoogleChatReaction[] }>(account, url.toString(), {
|
||||
method: "GET",
|
||||
});
|
||||
return result.reactions ?? [];
|
||||
}
|
||||
|
||||
export async function deleteGoogleChatReaction(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
reactionName: string;
|
||||
}): Promise<void> {
|
||||
const { account, reactionName } = params;
|
||||
const url = `${CHAT_API_BASE}/${reactionName}`;
|
||||
await fetchOk(account, url, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function findGoogleChatDirectMessage(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
userName: string;
|
||||
}): Promise<{ name?: string; displayName?: string } | null> {
|
||||
const { account, userName } = params;
|
||||
const url = new URL(`${CHAT_API_BASE}/spaces:findDirectMessage`);
|
||||
url.searchParams.set("name", userName);
|
||||
return await fetchJson<{ name?: string; displayName?: string }>(account, url.toString(), {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
export async function probeGoogleChat(account: ResolvedGoogleChatAccount): Promise<{
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const url = new URL(`${CHAT_API_BASE}/spaces`);
|
||||
url.searchParams.set("pageSize", "1");
|
||||
await fetchJson<Record<string, unknown>>(account, url.toString(), {
|
||||
method: "GET",
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: formatErrorMessage(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
24
openclaw/extensions/googlechat/src/approval-auth.test.ts
Normal file
24
openclaw/extensions/googlechat/src/approval-auth.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { googleChatApprovalAuth } from "./approval-auth.js";
|
||||
|
||||
describe("googleChatApprovalAuth", () => {
|
||||
it("authorizes stable users/* ids and ignores email-style approvers", () => {
|
||||
expect(
|
||||
googleChatApprovalAuth.authorizeActorAction({
|
||||
cfg: { channels: { googlechat: { dm: { allowFrom: ["users/123"] } } } },
|
||||
senderId: "users/123",
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
|
||||
expect(
|
||||
googleChatApprovalAuth.authorizeActorAction({
|
||||
cfg: { channels: { googlechat: { dm: { allowFrom: ["owner@example.com"] } } } },
|
||||
senderId: "users/attacker",
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
});
|
||||
});
|
||||
32
openclaw/extensions/googlechat/src/approval-auth.ts
Normal file
32
openclaw/extensions/googlechat/src/approval-auth.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import {
|
||||
createResolvedApproverActionAuthAdapter,
|
||||
resolveApprovalApprovers,
|
||||
} from "openclaw/plugin-sdk/approval-auth-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveGoogleChatAccount } from "./accounts.js";
|
||||
import { isGoogleChatUserTarget, normalizeGoogleChatTarget } from "./targets.js";
|
||||
|
||||
function normalizeGoogleChatApproverId(value: string | number): string | undefined {
|
||||
const normalized = normalizeGoogleChatTarget(String(value));
|
||||
if (!normalized || !isGoogleChatUserTarget(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
const suffix = normalizeLowercaseStringOrEmpty(normalized.slice("users/".length));
|
||||
if (!suffix || suffix.includes("@")) {
|
||||
return undefined;
|
||||
}
|
||||
return `users/${suffix}`;
|
||||
}
|
||||
|
||||
export 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,
|
||||
});
|
||||
},
|
||||
normalizeSenderId: (value) => normalizeGoogleChatApproverId(value),
|
||||
});
|
||||
170
openclaw/extensions/googlechat/src/auth.ts
Normal file
170
openclaw/extensions/googlechat/src/auth.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { GoogleAuth, OAuth2Client } from "google-auth-library";
|
||||
import { fetchWithSsrFGuard } from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
|
||||
const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
|
||||
const CHAT_ISSUER = "chat@system.gserviceaccount.com";
|
||||
// Google Workspace Add-ons use a different service account pattern
|
||||
const ADDON_ISSUER_PATTERN = /^service-\d+@gcp-sa-gsuiteaddons\.iam\.gserviceaccount\.com$/;
|
||||
const CHAT_CERTS_URL =
|
||||
"https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com";
|
||||
|
||||
// Size-capped to prevent unbounded growth in long-running deployments (#4948)
|
||||
const MAX_AUTH_CACHE_SIZE = 32;
|
||||
const authCache = new Map<string, { key: string; auth: GoogleAuth }>();
|
||||
const verifyClient = new OAuth2Client();
|
||||
|
||||
let cachedCerts: { fetchedAt: number; certs: Record<string, string> } | null = null;
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function buildAuthKey(account: ResolvedGoogleChatAccount): string {
|
||||
if (account.credentialsFile) {
|
||||
return `file:${account.credentialsFile}`;
|
||||
}
|
||||
if (account.credentials) {
|
||||
return `inline:${JSON.stringify(account.credentials)}`;
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth {
|
||||
const key = buildAuthKey(account);
|
||||
const cached = authCache.get(account.accountId);
|
||||
if (cached && cached.key === key) {
|
||||
return cached.auth;
|
||||
}
|
||||
|
||||
const evictOldest = () => {
|
||||
if (authCache.size > MAX_AUTH_CACHE_SIZE) {
|
||||
const oldest = authCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
authCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (account.credentialsFile) {
|
||||
const auth = new GoogleAuth({ keyFile: account.credentialsFile, scopes: [CHAT_SCOPE] });
|
||||
authCache.set(account.accountId, { key, auth });
|
||||
evictOldest();
|
||||
return auth;
|
||||
}
|
||||
|
||||
if (account.credentials) {
|
||||
const auth = new GoogleAuth({ credentials: account.credentials, scopes: [CHAT_SCOPE] });
|
||||
authCache.set(account.accountId, { key, auth });
|
||||
evictOldest();
|
||||
return auth;
|
||||
}
|
||||
|
||||
const auth = new GoogleAuth({ scopes: [CHAT_SCOPE] });
|
||||
authCache.set(account.accountId, { key, auth });
|
||||
evictOldest();
|
||||
return auth;
|
||||
}
|
||||
|
||||
export async function getGoogleChatAccessToken(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
): Promise<string> {
|
||||
const auth = getAuthInstance(account);
|
||||
const client = await auth.getClient();
|
||||
const access = await client.getAccessToken();
|
||||
const token = typeof access === "string" ? access : access?.token;
|
||||
if (!token) {
|
||||
throw new Error("Missing Google Chat access token");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function fetchChatCerts(): Promise<Record<string, string>> {
|
||||
const now = Date.now();
|
||||
if (cachedCerts && now - cachedCerts.fetchedAt < 10 * 60 * 1000) {
|
||||
return cachedCerts.certs;
|
||||
}
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: CHAT_CERTS_URL,
|
||||
auditContext: "googlechat.auth.certs",
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Chat certs (${response.status})`);
|
||||
}
|
||||
const certs = (await response.json()) as Record<string, string>;
|
||||
cachedCerts = { fetchedAt: now, certs };
|
||||
return certs;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export type GoogleChatAudienceType = "app-url" | "project-number";
|
||||
|
||||
export async function verifyGoogleChatRequest(params: {
|
||||
bearer?: string | null;
|
||||
audienceType?: GoogleChatAudienceType | null;
|
||||
audience?: string | null;
|
||||
expectedAddOnPrincipal?: string | null;
|
||||
}): Promise<{ ok: boolean; reason?: string }> {
|
||||
const bearer = params.bearer?.trim();
|
||||
if (!bearer) {
|
||||
return { ok: false, reason: "missing token" };
|
||||
}
|
||||
const audience = params.audience?.trim();
|
||||
if (!audience) {
|
||||
return { ok: false, reason: "missing audience" };
|
||||
}
|
||||
const audienceType = params.audienceType ?? null;
|
||||
|
||||
if (audienceType === "app-url") {
|
||||
try {
|
||||
const ticket = await verifyClient.verifyIdToken({
|
||||
idToken: bearer,
|
||||
audience,
|
||||
});
|
||||
const payload = ticket.getPayload();
|
||||
const email = normalizeLowercaseStringOrEmpty(payload?.email ?? "");
|
||||
if (!payload?.email_verified) {
|
||||
return { ok: false, reason: "email not verified" };
|
||||
}
|
||||
if (email === CHAT_ISSUER) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (!ADDON_ISSUER_PATTERN.test(email)) {
|
||||
return { ok: false, reason: `invalid issuer: ${email}` };
|
||||
}
|
||||
const expectedAddOnPrincipal = normalizeLowercaseStringOrEmpty(
|
||||
params.expectedAddOnPrincipal ?? "",
|
||||
);
|
||||
if (!expectedAddOnPrincipal) {
|
||||
return { ok: false, reason: "missing add-on principal binding" };
|
||||
}
|
||||
const tokenPrincipal = normalizeLowercaseStringOrEmpty(payload?.sub ?? "");
|
||||
if (!tokenPrincipal || tokenPrincipal !== expectedAddOnPrincipal) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `unexpected add-on principal: ${tokenPrincipal || "<missing>"}`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : "invalid token" };
|
||||
}
|
||||
}
|
||||
|
||||
if (audienceType === "project-number") {
|
||||
try {
|
||||
const certs = await fetchChatCerts();
|
||||
await verifyClient.verifySignedJwtWithCertsAsync(bearer, certs, audience, [CHAT_ISSUER]);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : "invalid token" };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, reason: "unsupported audience type" };
|
||||
}
|
||||
|
||||
export const GOOGLE_CHAT_SCOPE = CHAT_SCOPE;
|
||||
293
openclaw/extensions/googlechat/src/channel.adapters.ts
Normal file
293
openclaw/extensions/googlechat/src/channel.adapters.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import {
|
||||
composeAccountWarningCollectors,
|
||||
createAllowlistProviderOpenWarningCollector,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import {
|
||||
createChannelDirectoryAdapter,
|
||||
listResolvedDirectoryGroupEntriesFromMapKeys,
|
||||
listResolvedDirectoryUserEntriesFromAllowFrom,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type { OutboundMediaLoadOptions } from "openclaw/plugin-sdk/outbound-media";
|
||||
import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
type ResolvedGoogleChatAccount,
|
||||
chunkTextForOutbound,
|
||||
fetchRemoteMedia,
|
||||
isGoogleChatUserTarget,
|
||||
loadOutboundMediaFromUrl,
|
||||
missingTargetError,
|
||||
normalizeGoogleChatTarget,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveChannelMediaMaxBytes,
|
||||
resolveGoogleChatAccount,
|
||||
resolveGoogleChatOutboundSpace,
|
||||
type OpenClawConfig,
|
||||
} from "./channel.deps.runtime.js";
|
||||
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
|
||||
|
||||
const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(
|
||||
() => import("./channel.runtime.js"),
|
||||
"googleChatChannelRuntime",
|
||||
);
|
||||
|
||||
export const formatAllowFromEntry = (entry: string) =>
|
||||
normalizeLowercaseStringOrEmpty(
|
||||
entry
|
||||
.trim()
|
||||
.replace(/^(googlechat|google-chat|gchat):/i, "")
|
||||
.replace(/^user:/i, "")
|
||||
.replace(/^users\//i, ""),
|
||||
);
|
||||
|
||||
const collectGoogleChatGroupPolicyWarnings =
|
||||
createAllowlistProviderOpenWarningCollector<ResolvedGoogleChatAccount>({
|
||||
providerConfigPresent: (cfg) => cfg.channels?.googlechat !== undefined,
|
||||
resolveGroupPolicy: (account) => account.config.groupPolicy,
|
||||
buildOpenWarning: {
|
||||
surface: "Google Chat spaces",
|
||||
openBehavior: "allows any space to trigger (mention-gated)",
|
||||
remediation:
|
||||
'Set channels.googlechat.groupPolicy="allowlist" and configure channels.googlechat.groups',
|
||||
},
|
||||
});
|
||||
|
||||
const collectGoogleChatSecurityWarnings = composeAccountWarningCollectors<
|
||||
ResolvedGoogleChatAccount,
|
||||
{
|
||||
cfg: OpenClawConfig;
|
||||
account: ResolvedGoogleChatAccount;
|
||||
}
|
||||
>(
|
||||
collectGoogleChatGroupPolicyWarnings,
|
||||
(account) =>
|
||||
account.config.dm?.policy === "open" &&
|
||||
'- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".',
|
||||
);
|
||||
|
||||
export const googlechatGroupsAdapter = {
|
||||
resolveRequireMention: resolveGoogleChatGroupRequireMention,
|
||||
};
|
||||
|
||||
export const googlechatDirectoryAdapter = createChannelDirectoryAdapter({
|
||||
listPeers: async (params) =>
|
||||
listResolvedDirectoryUserEntriesFromAllowFrom<ResolvedGoogleChatAccount>({
|
||||
...params,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount),
|
||||
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
|
||||
normalizeId: (entry) => normalizeGoogleChatTarget(entry) ?? entry,
|
||||
}),
|
||||
listGroups: async (params) =>
|
||||
listResolvedDirectoryGroupEntriesFromMapKeys<ResolvedGoogleChatAccount>({
|
||||
...params,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount),
|
||||
resolveGroups: (account) => account.config.groups,
|
||||
}),
|
||||
});
|
||||
|
||||
export const googlechatSecurityAdapter = {
|
||||
dm: {
|
||||
channelKey: "googlechat",
|
||||
resolvePolicy: (account: ResolvedGoogleChatAccount) => account.config.dm?.policy,
|
||||
resolveAllowFrom: (account: ResolvedGoogleChatAccount) => account.config.dm?.allowFrom,
|
||||
allowFromPathSuffix: "dm.",
|
||||
normalizeEntry: (raw: string) => formatAllowFromEntry(raw),
|
||||
},
|
||||
collectWarnings: collectGoogleChatSecurityWarnings,
|
||||
};
|
||||
|
||||
export const googlechatThreadingAdapter = {
|
||||
scopedAccountReplyToMode: {
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
|
||||
resolveGoogleChatAccount({ cfg, accountId }),
|
||||
resolveReplyToMode: (account: ResolvedGoogleChatAccount, _chatType?: string | null) =>
|
||||
account.config.replyToMode,
|
||||
fallback: "off" as const,
|
||||
},
|
||||
};
|
||||
|
||||
export const googlechatPairingTextAdapter = {
|
||||
idLabel: "googlechatUserId",
|
||||
message: PAIRING_APPROVED_MESSAGE,
|
||||
normalizeAllowEntry: (entry: string) => formatAllowFromEntry(entry),
|
||||
notify: async ({
|
||||
cfg,
|
||||
id,
|
||||
message,
|
||||
accountId,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
id: string;
|
||||
message: string;
|
||||
accountId?: string | null;
|
||||
}) => {
|
||||
const account = resolveGoogleChatAccount({ cfg: cfg, accountId });
|
||||
if (account.credentialSource === "none") {
|
||||
return;
|
||||
}
|
||||
const user = normalizeGoogleChatTarget(id) ?? id;
|
||||
const target = isGoogleChatUserTarget(user) ? user : `users/${user}`;
|
||||
const space = await resolveGoogleChatOutboundSpace({ account, target });
|
||||
const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime();
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text: message,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const googlechatOutboundAdapter = {
|
||||
base: {
|
||||
deliveryMode: "direct" as const,
|
||||
chunker: chunkTextForOutbound,
|
||||
chunkerMode: "markdown" as const,
|
||||
textChunkLimit: 4000,
|
||||
sanitizeText: ({ text }: { text: string }) => sanitizeForPlainText(text),
|
||||
resolveTarget: ({ to }: { to?: string }) => {
|
||||
const trimmed = normalizeOptionalString(to) ?? "";
|
||||
|
||||
if (trimmed) {
|
||||
const normalized = normalizeGoogleChatTarget(trimmed);
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
|
||||
};
|
||||
}
|
||||
return { ok: true as const, to: normalized };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false as const,
|
||||
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
|
||||
};
|
||||
},
|
||||
},
|
||||
attachedResults: {
|
||||
channel: "googlechat" as const,
|
||||
sendText: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
text: string;
|
||||
accountId?: string | null;
|
||||
replyToId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
}) => {
|
||||
const account = resolveGoogleChatAccount({
|
||||
cfg: cfg,
|
||||
accountId,
|
||||
});
|
||||
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
|
||||
const thread =
|
||||
typeof threadId === "number" ? String(threadId) : (threadId ?? replyToId ?? undefined);
|
||||
const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime();
|
||||
const result = await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text,
|
||||
thread,
|
||||
});
|
||||
return {
|
||||
messageId: result?.messageName ?? "",
|
||||
chatId: space,
|
||||
};
|
||||
},
|
||||
sendMedia: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaAccess?: OutboundMediaLoadOptions["mediaAccess"];
|
||||
mediaLocalRoots?: OutboundMediaLoadOptions["mediaLocalRoots"];
|
||||
mediaReadFile?: OutboundMediaLoadOptions["mediaReadFile"];
|
||||
accountId?: string | null;
|
||||
replyToId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
}) => {
|
||||
if (!mediaUrl) {
|
||||
throw new Error("Google Chat mediaUrl is required.");
|
||||
}
|
||||
const account = resolveGoogleChatAccount({
|
||||
cfg: cfg,
|
||||
accountId,
|
||||
});
|
||||
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
|
||||
const thread =
|
||||
typeof threadId === "number" ? String(threadId) : (threadId ?? replyToId ?? undefined);
|
||||
const maxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg: cfg,
|
||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||
(
|
||||
cfg.channels?.googlechat as
|
||||
| { accounts?: Record<string, { mediaMaxMb?: number }>; mediaMaxMb?: number }
|
||||
| undefined
|
||||
)?.accounts?.[accountId]?.mediaMaxMb ??
|
||||
(cfg.channels?.googlechat as { mediaMaxMb?: number } | undefined)?.mediaMaxMb,
|
||||
accountId,
|
||||
});
|
||||
const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||
const loaded = /^https?:\/\//i.test(mediaUrl)
|
||||
? await fetchRemoteMedia({
|
||||
url: mediaUrl,
|
||||
maxBytes: effectiveMaxBytes,
|
||||
})
|
||||
: await loadOutboundMediaFromUrl(mediaUrl, {
|
||||
maxBytes: effectiveMaxBytes,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
});
|
||||
const { sendGoogleChatMessage, uploadGoogleChatAttachment } =
|
||||
await loadGoogleChatChannelRuntime();
|
||||
const upload = await uploadGoogleChatAttachment({
|
||||
account,
|
||||
space,
|
||||
filename: loaded.fileName ?? "attachment",
|
||||
buffer: loaded.buffer,
|
||||
contentType: loaded.contentType,
|
||||
});
|
||||
const result = await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text,
|
||||
thread,
|
||||
attachments: upload.attachmentUploadToken
|
||||
? [
|
||||
{
|
||||
attachmentUploadToken: upload.attachmentUploadToken,
|
||||
contentName: loaded.fileName,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
return {
|
||||
messageId: result?.messageName ?? "",
|
||||
chatId: space,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
28
openclaw/extensions/googlechat/src/channel.deps.runtime.ts
Normal file
28
openclaw/extensions/googlechat/src/channel.deps.runtime.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export {
|
||||
buildChannelConfigSchema,
|
||||
chunkTextForOutbound,
|
||||
createAccountStatusSink,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
fetchRemoteMedia,
|
||||
GoogleChatConfigSchema,
|
||||
loadOutboundMediaFromUrl,
|
||||
missingTargetError,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveChannelMediaMaxBytes,
|
||||
runPassiveAccountLifecycle,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelStatusIssue,
|
||||
type OpenClawConfig,
|
||||
} from "../runtime-api.js";
|
||||
export {
|
||||
listGoogleChatAccountIds,
|
||||
resolveDefaultGoogleChatAccountId,
|
||||
resolveGoogleChatAccount,
|
||||
type ResolvedGoogleChatAccount,
|
||||
} from "./accounts.js";
|
||||
export {
|
||||
isGoogleChatSpaceTarget,
|
||||
isGoogleChatUserTarget,
|
||||
normalizeGoogleChatTarget,
|
||||
resolveGoogleChatOutboundSpace,
|
||||
} from "./targets.js";
|
||||
17
openclaw/extensions/googlechat/src/channel.runtime.ts
Normal file
17
openclaw/extensions/googlechat/src/channel.runtime.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import {
|
||||
probeGoogleChat as probeGoogleChatImpl,
|
||||
sendGoogleChatMessage as sendGoogleChatMessageImpl,
|
||||
uploadGoogleChatAttachment as uploadGoogleChatAttachmentImpl,
|
||||
} from "./api.js";
|
||||
import {
|
||||
resolveGoogleChatWebhookPath as resolveGoogleChatWebhookPathImpl,
|
||||
startGoogleChatMonitor as startGoogleChatMonitorImpl,
|
||||
} from "./monitor.js";
|
||||
|
||||
export const googleChatChannelRuntime = {
|
||||
probeGoogleChat: probeGoogleChatImpl,
|
||||
sendGoogleChatMessage: sendGoogleChatMessageImpl,
|
||||
uploadGoogleChatAttachment: uploadGoogleChatAttachmentImpl,
|
||||
resolveGoogleChatWebhookPath: resolveGoogleChatWebhookPathImpl,
|
||||
startGoogleChatMonitor: startGoogleChatMonitorImpl,
|
||||
};
|
||||
695
openclaw/extensions/googlechat/src/channel.test.ts
Normal file
695
openclaw/extensions/googlechat/src/channel.test.ts
Normal file
|
|
@ -0,0 +1,695 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createDirectoryTestRuntime,
|
||||
expectDirectorySurface,
|
||||
} from "../../../test/helpers/plugins/directory.ts";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
googlechatDirectoryAdapter,
|
||||
googlechatOutboundAdapter,
|
||||
googlechatPairingTextAdapter,
|
||||
googlechatSecurityAdapter,
|
||||
googlechatThreadingAdapter,
|
||||
} from "./channel.adapters.js";
|
||||
|
||||
const uploadGoogleChatAttachmentMock = vi.hoisted(() => vi.fn());
|
||||
const sendGoogleChatMessageMock = vi.hoisted(() => vi.fn());
|
||||
const resolveGoogleChatAccountMock = vi.hoisted(() => vi.fn());
|
||||
const resolveGoogleChatOutboundSpaceMock = vi.hoisted(() => vi.fn());
|
||||
const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
|
||||
const loadOutboundMediaFromUrlMock = vi.hoisted(() => vi.fn());
|
||||
const probeGoogleChatMock = vi.hoisted(() => vi.fn());
|
||||
const startGoogleChatMonitorMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
function normalizeGoogleChatTarget(raw?: string | null): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, "");
|
||||
const normalized = withoutPrefix
|
||||
.replace(/^user:(users\/)?/i, "users/")
|
||||
.replace(/^space:(spaces\/)?/i, "spaces/");
|
||||
if (normalized.toLowerCase().startsWith("users/")) {
|
||||
const suffix = normalized.slice("users/".length);
|
||||
return suffix.includes("@") ? `users/${suffix.toLowerCase()}` : normalized;
|
||||
}
|
||||
if (normalized.toLowerCase().startsWith("spaces/")) {
|
||||
return normalized;
|
||||
}
|
||||
if (normalized.includes("@")) {
|
||||
return `users/${normalized.toLowerCase()}`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveGoogleChatAccountImpl(params: { cfg: OpenClawConfig; accountId?: string | null }) {
|
||||
const accountId = params.accountId?.trim() || DEFAULT_ACCOUNT_ID;
|
||||
const channelConfig = (params.cfg.channels?.googlechat ?? {}) as Record<string, unknown>;
|
||||
const accounts =
|
||||
(channelConfig.accounts as Record<string, Record<string, unknown>> | undefined) ?? {};
|
||||
const scoped = accountId === DEFAULT_ACCOUNT_ID ? {} : (accounts[accountId] ?? {});
|
||||
const config = { ...channelConfig, ...scoped } as Record<string, unknown>;
|
||||
const serviceAccount = config.serviceAccount;
|
||||
return {
|
||||
accountId,
|
||||
name: typeof config.name === "string" ? config.name : undefined,
|
||||
enabled: channelConfig.enabled !== false && scoped.enabled !== false,
|
||||
config,
|
||||
credentialSource: serviceAccount ? ("inline" as const) : ("none" as const),
|
||||
};
|
||||
}
|
||||
|
||||
function mockGoogleChatOutboundSpaceResolution() {
|
||||
resolveGoogleChatOutboundSpaceMock.mockImplementation(async ({ target }: { target: string }) => {
|
||||
const normalized = normalizeGoogleChatTarget(target);
|
||||
if (!normalized) {
|
||||
throw new Error("Missing Google Chat target.");
|
||||
}
|
||||
return normalized.toLowerCase().startsWith("users/")
|
||||
? `spaces/DM-${normalized.slice("users/".length)}`
|
||||
: normalized.replace(/\/messages\/.+$/, "");
|
||||
});
|
||||
}
|
||||
|
||||
function mockGoogleChatMediaLoaders() {
|
||||
loadOutboundMediaFromUrlMock.mockImplementation(async (mediaUrl: string) => ({
|
||||
buffer: Buffer.from("default-bytes"),
|
||||
fileName: mediaUrl.split("/").pop() || "attachment",
|
||||
contentType: "application/octet-stream",
|
||||
}));
|
||||
fetchRemoteMediaMock.mockImplementation(async () => ({
|
||||
buffer: Buffer.from("remote-bytes"),
|
||||
fileName: "remote.png",
|
||||
contentType: "image/png",
|
||||
}));
|
||||
}
|
||||
|
||||
vi.mock("./channel.runtime.js", () => {
|
||||
return {
|
||||
googleChatChannelRuntime: {
|
||||
probeGoogleChat: (...args: unknown[]) => probeGoogleChatMock(...args),
|
||||
resolveGoogleChatWebhookPath: () => "/googlechat/webhook",
|
||||
sendGoogleChatMessage: (...args: unknown[]) => sendGoogleChatMessageMock(...args),
|
||||
startGoogleChatMonitor: (...args: unknown[]) => startGoogleChatMonitorMock(...args),
|
||||
uploadGoogleChatAttachment: (...args: unknown[]) => uploadGoogleChatAttachmentMock(...args),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./channel.deps.runtime.js", () => {
|
||||
return {
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
GoogleChatConfigSchema: {},
|
||||
buildChannelConfigSchema: () => ({}),
|
||||
chunkTextForOutbound: (text: string, maxChars: number) => {
|
||||
const words = text.split(/\s+/).filter(Boolean);
|
||||
const chunks: string[] = [];
|
||||
let current = "";
|
||||
for (const word of words) {
|
||||
const next = current ? `${current} ${word}` : word;
|
||||
if (current && next.length > maxChars) {
|
||||
chunks.push(current);
|
||||
current = word;
|
||||
continue;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
if (current) {
|
||||
chunks.push(current);
|
||||
}
|
||||
return chunks;
|
||||
},
|
||||
createAccountStatusSink: () => () => {},
|
||||
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMediaMock(...args),
|
||||
getChatChannelMeta: (id: string) => ({ id, name: id }),
|
||||
isGoogleChatSpaceTarget: (value: string) => value.toLowerCase().startsWith("spaces/"),
|
||||
isGoogleChatUserTarget: (value: string) => value.toLowerCase().startsWith("users/"),
|
||||
listGoogleChatAccountIds: (cfg: OpenClawConfig) => {
|
||||
const ids = Object.keys(cfg.channels?.googlechat?.accounts ?? {});
|
||||
return ids.length > 0 ? ids : ["default"];
|
||||
},
|
||||
loadOutboundMediaFromUrl: (...args: unknown[]) => loadOutboundMediaFromUrlMock(...args),
|
||||
missingTargetError: (channel: string, hint: string) =>
|
||||
new Error(`${channel} target is required (${hint})`),
|
||||
normalizeGoogleChatTarget,
|
||||
PAIRING_APPROVED_MESSAGE: "approved",
|
||||
resolveChannelMediaMaxBytes: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
resolveChannelLimitMb: (args: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
}) => number | undefined;
|
||||
accountId?: string;
|
||||
}) => {
|
||||
const limitMb = params.resolveChannelLimitMb({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
return typeof limitMb === "number" ? limitMb * 1024 * 1024 : undefined;
|
||||
},
|
||||
resolveDefaultGoogleChatAccountId: () => "default",
|
||||
resolveGoogleChatAccount: (...args: Parameters<typeof resolveGoogleChatAccountImpl>) =>
|
||||
resolveGoogleChatAccountMock(...args),
|
||||
resolveGoogleChatOutboundSpace: (...args: unknown[]) =>
|
||||
resolveGoogleChatOutboundSpaceMock(...args),
|
||||
runPassiveAccountLifecycle: async (params: { start: () => Promise<unknown> }) =>
|
||||
await params.start(),
|
||||
};
|
||||
});
|
||||
|
||||
resolveGoogleChatAccountMock.mockImplementation(resolveGoogleChatAccountImpl);
|
||||
mockGoogleChatOutboundSpaceResolution();
|
||||
mockGoogleChatMediaLoaders();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveGoogleChatAccountMock.mockImplementation(resolveGoogleChatAccountImpl);
|
||||
mockGoogleChatOutboundSpaceResolution();
|
||||
mockGoogleChatMediaLoaders();
|
||||
});
|
||||
|
||||
function createGoogleChatCfg(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
client_email: "bot@example.com",
|
||||
private_key: "test-key", // pragma: allowlist secret
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setupRuntimeMediaMocks(params: { loadFileName: string; loadBytes: string }) {
|
||||
const loadOutboundMediaFromUrl = vi.fn(async () => ({
|
||||
buffer: Buffer.from(params.loadBytes),
|
||||
fileName: params.loadFileName,
|
||||
contentType: "image/png",
|
||||
}));
|
||||
const fetchRemoteMedia = vi.fn(async () => ({
|
||||
buffer: Buffer.from("remote-bytes"),
|
||||
fileName: "remote.png",
|
||||
contentType: "image/png",
|
||||
}));
|
||||
|
||||
loadOutboundMediaFromUrlMock.mockImplementation(loadOutboundMediaFromUrl);
|
||||
fetchRemoteMediaMock.mockImplementation(fetchRemoteMedia);
|
||||
|
||||
return { loadOutboundMediaFromUrl, fetchRemoteMedia };
|
||||
}
|
||||
|
||||
describe("googlechatPlugin outbound sendMedia", () => {
|
||||
it("chunks outbound text without requiring Google Chat runtime initialization", () => {
|
||||
const chunker = googlechatOutboundAdapter.base.chunker;
|
||||
|
||||
expect(chunker("alpha beta", 5)).toEqual(["alpha", "beta"]);
|
||||
});
|
||||
|
||||
it("loads local media with mediaLocalRoots via runtime media loader", async () => {
|
||||
const { loadOutboundMediaFromUrl, fetchRemoteMedia } = setupRuntimeMediaMocks({
|
||||
loadFileName: "image.png",
|
||||
loadBytes: "image-bytes",
|
||||
});
|
||||
|
||||
uploadGoogleChatAttachmentMock.mockResolvedValue({
|
||||
attachmentUploadToken: "token-1",
|
||||
});
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
});
|
||||
|
||||
const cfg = createGoogleChatCfg();
|
||||
|
||||
const result = await googlechatOutboundAdapter.attachedResults.sendMedia({
|
||||
cfg,
|
||||
to: "spaces/AAA",
|
||||
text: "caption",
|
||||
mediaUrl: "/tmp/workspace/image.png",
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(loadOutboundMediaFromUrl).toHaveBeenCalledWith(
|
||||
"/tmp/workspace/image.png",
|
||||
expect.objectContaining({
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
}),
|
||||
);
|
||||
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
||||
expect(uploadGoogleChatAttachmentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/AAA",
|
||||
filename: "image.png",
|
||||
contentType: "image/png",
|
||||
}),
|
||||
);
|
||||
expect(sendGoogleChatMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/AAA",
|
||||
text: "caption",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
messageId: "spaces/AAA/messages/msg-1",
|
||||
chatId: "spaces/AAA",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps remote URL media fetch on fetchRemoteMedia with maxBytes cap", async () => {
|
||||
const { loadOutboundMediaFromUrl, fetchRemoteMedia } = setupRuntimeMediaMocks({
|
||||
loadFileName: "unused.png",
|
||||
loadBytes: "should-not-be-used",
|
||||
});
|
||||
|
||||
uploadGoogleChatAttachmentMock.mockResolvedValue({
|
||||
attachmentUploadToken: "token-2",
|
||||
});
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-2",
|
||||
});
|
||||
|
||||
const cfg = createGoogleChatCfg();
|
||||
|
||||
const result = await googlechatOutboundAdapter.attachedResults.sendMedia({
|
||||
cfg,
|
||||
to: "spaces/AAA",
|
||||
text: "caption",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://example.com/image.png",
|
||||
maxBytes: 20 * 1024 * 1024,
|
||||
}),
|
||||
);
|
||||
expect(loadOutboundMediaFromUrl).not.toHaveBeenCalled();
|
||||
expect(uploadGoogleChatAttachmentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/AAA",
|
||||
filename: "remote.png",
|
||||
contentType: "image/png",
|
||||
}),
|
||||
);
|
||||
expect(sendGoogleChatMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/AAA",
|
||||
text: "caption",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
messageId: "spaces/AAA/messages/msg-2",
|
||||
chatId: "spaces/AAA",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechatPlugin threading", () => {
|
||||
it("honors per-account replyToMode overrides", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
replyToMode: "all",
|
||||
accounts: {
|
||||
work: {
|
||||
replyToMode: "first",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const workAccount = googlechatThreadingAdapter.scopedAccountReplyToMode.resolveAccount(
|
||||
cfg,
|
||||
"work",
|
||||
);
|
||||
const defaultAccount = googlechatThreadingAdapter.scopedAccountReplyToMode.resolveAccount(
|
||||
cfg,
|
||||
"default",
|
||||
);
|
||||
|
||||
expect(
|
||||
googlechatThreadingAdapter.scopedAccountReplyToMode.resolveReplyToMode(workAccount),
|
||||
).toBe("first");
|
||||
expect(
|
||||
googlechatThreadingAdapter.scopedAccountReplyToMode.resolveReplyToMode(defaultAccount),
|
||||
).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
const resolveTarget = googlechatOutboundAdapter.base.resolveTarget;
|
||||
|
||||
describe("googlechatPlugin outbound resolveTarget", () => {
|
||||
it("resolves valid chat targets", () => {
|
||||
const result = resolveTarget({
|
||||
to: "spaces/AAA",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
expect(result.to).toBe("spaces/AAA");
|
||||
});
|
||||
|
||||
it("resolves email targets", () => {
|
||||
const result = resolveTarget({
|
||||
to: "user@example.com",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
expect(result.to).toBe("users/user@example.com");
|
||||
});
|
||||
|
||||
it("errors on invalid targets", () => {
|
||||
const result = resolveTarget({
|
||||
to: " ",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("Expected invalid target to fail");
|
||||
}
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it("errors when no target is provided", () => {
|
||||
const result = resolveTarget({
|
||||
to: undefined,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("Expected missing target to fail");
|
||||
}
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechatPlugin outbound cfg threading", () => {
|
||||
it("preserves accountId when sending pairing approvals", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
work: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const account = {
|
||||
accountId: "work",
|
||||
config: {},
|
||||
credentialSource: "inline" as const,
|
||||
};
|
||||
resolveGoogleChatAccountMock.mockReturnValue(account);
|
||||
resolveGoogleChatOutboundSpaceMock.mockResolvedValue("spaces/WORK");
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/WORK/messages/msg-1",
|
||||
});
|
||||
|
||||
await googlechatPairingTextAdapter.notify({
|
||||
cfg: cfg as never,
|
||||
id: "user@example.com",
|
||||
message: googlechatPairingTextAdapter.message,
|
||||
accountId: "work",
|
||||
} as never);
|
||||
|
||||
expect(resolveGoogleChatAccountMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId: "work",
|
||||
});
|
||||
expect(sendGoogleChatMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
account,
|
||||
space: "spaces/WORK",
|
||||
text: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("threads resolved cfg into sendText account resolution", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const account = {
|
||||
accountId: "default",
|
||||
config: {},
|
||||
credentialSource: "inline" as const,
|
||||
};
|
||||
resolveGoogleChatAccountMock.mockReturnValue(account);
|
||||
resolveGoogleChatOutboundSpaceMock.mockResolvedValue("spaces/AAA");
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
});
|
||||
|
||||
await googlechatOutboundAdapter.attachedResults.sendText({
|
||||
cfg: cfg as never,
|
||||
to: "users/123",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolveGoogleChatAccountMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(sendGoogleChatMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("threads resolved cfg into sendMedia account and media loading path", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
},
|
||||
mediaMaxMb: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
const account = {
|
||||
accountId: "default",
|
||||
config: { mediaMaxMb: 20 },
|
||||
credentialSource: "inline" as const,
|
||||
};
|
||||
const { fetchRemoteMedia } = setupRuntimeMediaMocks({
|
||||
loadFileName: "unused.png",
|
||||
loadBytes: "should-not-be-used",
|
||||
});
|
||||
|
||||
resolveGoogleChatAccountMock.mockReturnValue(account);
|
||||
resolveGoogleChatOutboundSpaceMock.mockResolvedValue("spaces/AAA");
|
||||
uploadGoogleChatAttachmentMock.mockResolvedValue({
|
||||
attachmentUploadToken: "token-1",
|
||||
});
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-2",
|
||||
});
|
||||
|
||||
await googlechatOutboundAdapter.attachedResults.sendMedia({
|
||||
cfg: cfg as never,
|
||||
to: "users/123",
|
||||
text: "photo",
|
||||
mediaUrl: "https://example.com/file.png",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolveGoogleChatAccountMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://example.com/file.png",
|
||||
maxBytes: 8 * 1024 * 1024,
|
||||
}),
|
||||
);
|
||||
expect(uploadGoogleChatAttachmentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
filename: "remote.png",
|
||||
}),
|
||||
);
|
||||
expect(sendGoogleChatMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
account,
|
||||
attachments: [{ attachmentUploadToken: "token-1", contentName: "remote.png" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends media without requiring Google Chat runtime initialization", async () => {
|
||||
const { loadOutboundMediaFromUrl } = setupRuntimeMediaMocks({
|
||||
loadFileName: "image.png",
|
||||
loadBytes: "image-bytes",
|
||||
});
|
||||
|
||||
uploadGoogleChatAttachmentMock.mockResolvedValue({
|
||||
attachmentUploadToken: "token-cold",
|
||||
});
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-cold",
|
||||
});
|
||||
|
||||
const cfg = createGoogleChatCfg();
|
||||
|
||||
await expect(
|
||||
googlechatOutboundAdapter.attachedResults.sendMedia({
|
||||
cfg,
|
||||
to: "spaces/AAA",
|
||||
text: "caption",
|
||||
mediaUrl: "/tmp/workspace/image.png",
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
accountId: "default",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
messageId: "spaces/AAA/messages/msg-cold",
|
||||
chatId: "spaces/AAA",
|
||||
});
|
||||
|
||||
expect(loadOutboundMediaFromUrl).toHaveBeenCalledWith(
|
||||
"/tmp/workspace/image.png",
|
||||
expect.objectContaining({
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechat directory", () => {
|
||||
const runtimeEnv = createDirectoryTestRuntime() as never;
|
||||
|
||||
it("lists peers and groups from config", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: { allowFrom: ["users/alice", "googlechat:bob"] },
|
||||
groups: {
|
||||
"spaces/AAA": {},
|
||||
"spaces/BBB": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const directory = expectDirectorySurface(googlechatDirectoryAdapter);
|
||||
|
||||
await expect(
|
||||
directory.listPeers({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "user", id: "users/alice" },
|
||||
{ kind: "user", id: "bob" },
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
directory.listGroups({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "group", id: "spaces/AAA" },
|
||||
{ kind: "group", id: "spaces/BBB" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes spaced provider-prefixed dm allowlist entries", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: { allowFrom: [" users/alice ", " googlechat:user:Bob@Example.com "] },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const directory = expectDirectorySurface(googlechatDirectoryAdapter);
|
||||
|
||||
await expect(
|
||||
directory.listPeers({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "user", id: "users/alice" },
|
||||
{ kind: "user", id: "users/bob@example.com" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechatPlugin security", () => {
|
||||
it("normalizes prefixed DM allowlist entries to lowercase user ids", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
allowFrom: [" googlechat:user:Bob@Example.com "],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const account = resolveGoogleChatAccountImpl({ cfg, accountId: "default" });
|
||||
|
||||
expect(googlechatSecurityAdapter.dm.resolvePolicy(account)).toBe("allowlist");
|
||||
expect(googlechatSecurityAdapter.dm.resolveAllowFrom(account)).toEqual([
|
||||
" googlechat:user:Bob@Example.com ",
|
||||
]);
|
||||
expect(googlechatSecurityAdapter.dm.normalizeEntry(" googlechat:user:Bob@Example.com ")).toBe(
|
||||
"bob@example.com",
|
||||
);
|
||||
expect(googlechatPairingTextAdapter.normalizeAllowEntry(" users/Alice@Example.com ")).toBe(
|
||||
"alice@example.com",
|
||||
);
|
||||
});
|
||||
});
|
||||
243
openclaw/extensions/googlechat/src/channel.ts
Normal file
243
openclaw/extensions/googlechat/src/channel.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
createScopedChannelConfigAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { googlechatMessageActions } from "./actions.js";
|
||||
import { googleChatApprovalAuth } from "./approval-auth.js";
|
||||
import {
|
||||
formatAllowFromEntry,
|
||||
googlechatDirectoryAdapter,
|
||||
googlechatGroupsAdapter,
|
||||
googlechatOutboundAdapter,
|
||||
googlechatPairingTextAdapter,
|
||||
googlechatSecurityAdapter,
|
||||
googlechatThreadingAdapter,
|
||||
} from "./channel.adapters.js";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
GoogleChatConfigSchema,
|
||||
isGoogleChatSpaceTarget,
|
||||
isGoogleChatUserTarget,
|
||||
listGoogleChatAccountIds,
|
||||
normalizeGoogleChatTarget,
|
||||
resolveDefaultGoogleChatAccountId,
|
||||
resolveGoogleChatAccount,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelStatusIssue,
|
||||
type ResolvedGoogleChatAccount,
|
||||
} from "./channel.deps.runtime.js";
|
||||
import { collectGoogleChatMutableAllowlistWarnings } from "./doctor.js";
|
||||
import { startGoogleChatGatewayAccount } from "./gateway.js";
|
||||
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
|
||||
import { googlechatSetupAdapter } from "./setup-core.js";
|
||||
import { googlechatSetupWizard } from "./setup-surface.js";
|
||||
|
||||
const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(
|
||||
() => import("./channel.runtime.js"),
|
||||
"googleChatChannelRuntime",
|
||||
);
|
||||
|
||||
const meta = {
|
||||
id: "googlechat",
|
||||
label: "Google Chat",
|
||||
selectionLabel: "Google Chat (Chat API)",
|
||||
docsPath: "/channels/googlechat",
|
||||
docsLabel: "googlechat",
|
||||
blurb: "Google Workspace Chat app with HTTP webhook.",
|
||||
aliases: ["gchat", "google-chat"],
|
||||
order: 55,
|
||||
detailLabel: "Google Chat",
|
||||
systemImage: "message.badge",
|
||||
markdownCapable: true,
|
||||
};
|
||||
|
||||
const googleChatConfigAdapter = createScopedChannelConfigAdapter<ResolvedGoogleChatAccount>({
|
||||
sectionKey: "googlechat",
|
||||
listAccountIds: listGoogleChatAccountIds,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount),
|
||||
defaultAccountId: resolveDefaultGoogleChatAccountId,
|
||||
clearBaseFields: [
|
||||
"serviceAccount",
|
||||
"serviceAccountFile",
|
||||
"audienceType",
|
||||
"audience",
|
||||
"webhookPath",
|
||||
"webhookUrl",
|
||||
"botUser",
|
||||
"name",
|
||||
],
|
||||
resolveAllowFrom: (account: ResolvedGoogleChatAccount) => account.config.dm?.allowFrom,
|
||||
formatAllowFrom: (allowFrom) =>
|
||||
formatNormalizedAllowFromEntries({
|
||||
allowFrom,
|
||||
normalizeEntry: formatAllowFromEntry,
|
||||
}),
|
||||
resolveDefaultTo: (account: ResolvedGoogleChatAccount) => account.config.defaultTo,
|
||||
});
|
||||
|
||||
const googlechatActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: (ctx) => googlechatMessageActions.describeMessageTool?.(ctx) ?? null,
|
||||
extractToolSend: (ctx) => googlechatMessageActions.extractToolSend?.(ctx) ?? null,
|
||||
handleAction: async (ctx) => {
|
||||
if (!googlechatMessageActions.handleAction) {
|
||||
throw new Error("Google Chat actions are not available.");
|
||||
}
|
||||
return await googlechatMessageActions.handleAction(ctx);
|
||||
},
|
||||
};
|
||||
|
||||
export const googlechatPlugin = createChatChannelPlugin({
|
||||
base: {
|
||||
id: "googlechat",
|
||||
meta: { ...meta },
|
||||
setup: googlechatSetupAdapter,
|
||||
setupWizard: googlechatSetupWizard,
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "thread"],
|
||||
reactions: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
reload: { configPrefixes: ["channels.googlechat"] },
|
||||
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
|
||||
config: {
|
||||
...googleChatConfigAdapter,
|
||||
isConfigured: (account) => account.credentialSource !== "none",
|
||||
describeAccount: (account) =>
|
||||
describeAccountSnapshot({
|
||||
account,
|
||||
configured: account.credentialSource !== "none",
|
||||
extra: {
|
||||
credentialSource: account.credentialSource,
|
||||
},
|
||||
}),
|
||||
},
|
||||
approvalCapability: googleChatApprovalAuth,
|
||||
secrets: {
|
||||
secretTargetRegistryEntries,
|
||||
collectRuntimeConfigAssignments,
|
||||
},
|
||||
groups: googlechatGroupsAdapter,
|
||||
messaging: {
|
||||
normalizeTarget: normalizeGoogleChatTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw, normalized) => {
|
||||
const value = normalized ?? raw.trim();
|
||||
return isGoogleChatSpaceTarget(value) || isGoogleChatUserTarget(value);
|
||||
},
|
||||
hint: "<spaces/{space}|users/{user}>",
|
||||
},
|
||||
},
|
||||
directory: googlechatDirectoryAdapter,
|
||||
resolver: {
|
||||
resolveTargets: async ({ inputs, kind }) => {
|
||||
const resolved = inputs.map((input) => {
|
||||
const normalized = normalizeGoogleChatTarget(input);
|
||||
if (!normalized) {
|
||||
return { input, resolved: false, note: "empty target" };
|
||||
}
|
||||
if (kind === "user" && isGoogleChatUserTarget(normalized)) {
|
||||
return { input, resolved: true, id: normalized };
|
||||
}
|
||||
if (kind === "group" && isGoogleChatSpaceTarget(normalized)) {
|
||||
return { input, resolved: true, id: normalized };
|
||||
}
|
||||
return {
|
||||
input,
|
||||
resolved: false,
|
||||
note: "use spaces/{space} or users/{user}",
|
||||
};
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
},
|
||||
actions: googlechatActions,
|
||||
doctor: {
|
||||
dmAllowFromMode: "nestedOnly",
|
||||
groupModel: "route",
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
warnOnEmptyGroupSenderAllowlist: false,
|
||||
collectMutableAllowlistWarnings: collectGoogleChatMutableAllowlistWarnings,
|
||||
},
|
||||
status: createComputedAccountStatusAdapter<ResolvedGoogleChatAccount>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
collectStatusIssues: (accounts): ChannelStatusIssue[] =>
|
||||
accounts.flatMap((entry) => {
|
||||
const accountId = entry.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const enabled = entry.enabled !== false;
|
||||
const configured = entry.configured === true;
|
||||
if (!enabled || !configured) {
|
||||
return [];
|
||||
}
|
||||
const issues: ChannelStatusIssue[] = [];
|
||||
if (!entry.audience) {
|
||||
issues.push({
|
||||
channel: "googlechat",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "Google Chat audience is missing (set channels.googlechat.audience).",
|
||||
fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
|
||||
});
|
||||
}
|
||||
if (!entry.audienceType) {
|
||||
issues.push({
|
||||
channel: "googlechat",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "Google Chat audienceType is missing (app-url or project-number).",
|
||||
fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}),
|
||||
buildChannelSummary: ({ snapshot }) =>
|
||||
buildPassiveProbedChannelStatusSummary(snapshot, {
|
||||
credentialSource: snapshot.credentialSource ?? "none",
|
||||
audienceType: snapshot.audienceType ?? null,
|
||||
audience: snapshot.audience ?? null,
|
||||
webhookPath: snapshot.webhookPath ?? null,
|
||||
webhookUrl: snapshot.webhookUrl ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account }) =>
|
||||
(await loadGoogleChatChannelRuntime()).probeGoogleChat(account),
|
||||
resolveAccountSnapshot: ({ account }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.credentialSource !== "none",
|
||||
extra: {
|
||||
credentialSource: account.credentialSource,
|
||||
audienceType: account.config.audienceType,
|
||||
audience: account.config.audience,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
dmPolicy: account.config.dm?.policy ?? "pairing",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
gateway: {
|
||||
startAccount: startGoogleChatGatewayAccount,
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
text: googlechatPairingTextAdapter,
|
||||
},
|
||||
security: googlechatSecurityAdapter,
|
||||
threading: googlechatThreadingAdapter,
|
||||
outbound: googlechatOutboundAdapter,
|
||||
});
|
||||
3
openclaw/extensions/googlechat/src/config-schema.ts
Normal file
3
openclaw/extensions/googlechat/src/config-schema.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { buildChannelConfigSchema, GoogleChatConfigSchema } from "openclaw/plugin-sdk/googlechat";
|
||||
|
||||
export const GoogleChatChannelConfigSchema = buildChannelConfigSchema(GoogleChatConfigSchema);
|
||||
57
openclaw/extensions/googlechat/src/doctor.ts
Normal file
57
openclaw/extensions/googlechat/src/doctor.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function isGoogleChatMutableAllowEntry(raw: string): boolean {
|
||||
const text = raw.trim();
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const withoutPrefix = text.replace(/^(googlechat|google-chat|gchat):/i, "").trim();
|
||||
if (!withoutPrefix) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const withoutUsers = withoutPrefix.replace(/^users\//i, "");
|
||||
return withoutUsers.includes("@");
|
||||
}
|
||||
|
||||
export const collectGoogleChatMutableAllowlistWarnings =
|
||||
createDangerousNameMatchingMutableAllowlistWarningCollector({
|
||||
channel: "googlechat",
|
||||
detector: isGoogleChatMutableAllowEntry,
|
||||
collectLists: (scope) => {
|
||||
const lists = [
|
||||
{
|
||||
pathLabel: `${scope.prefix}.groupAllowFrom`,
|
||||
list: scope.account.groupAllowFrom,
|
||||
},
|
||||
];
|
||||
const dm = asObjectRecord(scope.account.dm);
|
||||
if (dm) {
|
||||
lists.push({
|
||||
pathLabel: `${scope.prefix}.dm.allowFrom`,
|
||||
list: dm.allowFrom,
|
||||
});
|
||||
}
|
||||
const groups = asObjectRecord(scope.account.groups);
|
||||
if (groups) {
|
||||
for (const [groupKey, groupRaw] of Object.entries(groups)) {
|
||||
const group = asObjectRecord(groupRaw);
|
||||
if (!group) {
|
||||
continue;
|
||||
}
|
||||
lists.push({
|
||||
pathLabel: `${scope.prefix}.groups.${groupKey}.users`,
|
||||
list: group.users,
|
||||
});
|
||||
}
|
||||
}
|
||||
return lists;
|
||||
},
|
||||
});
|
||||
63
openclaw/extensions/googlechat/src/gateway.ts
Normal file
63
openclaw/extensions/googlechat/src/gateway.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import {
|
||||
createAccountStatusSink,
|
||||
runPassiveAccountLifecycle,
|
||||
} from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import type { GoogleChatRuntimeEnv } from "./monitor-types.js";
|
||||
|
||||
const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(
|
||||
() => import("./channel.runtime.js"),
|
||||
"googleChatChannelRuntime",
|
||||
);
|
||||
|
||||
export async function startGoogleChatGatewayAccount(ctx: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
cfg: OpenClawConfig;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
abortSignal: AbortSignal;
|
||||
setStatus: (next: ChannelAccountSnapshot) => void;
|
||||
log?: {
|
||||
info?: (message: string) => void;
|
||||
};
|
||||
}): Promise<void> {
|
||||
const account = ctx.account;
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: account.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
ctx.log?.info?.(`[${account.accountId}] starting Google Chat webhook`);
|
||||
const { resolveGoogleChatWebhookPath, startGoogleChatMonitor } =
|
||||
await loadGoogleChatChannelRuntime();
|
||||
statusSink({
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
webhookPath: resolveGoogleChatWebhookPath({ account }),
|
||||
audienceType: account.config.audienceType,
|
||||
audience: account.config.audience,
|
||||
});
|
||||
await runPassiveAccountLifecycle({
|
||||
abortSignal: ctx.abortSignal,
|
||||
start: async () =>
|
||||
await startGoogleChatMonitor({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
statusSink,
|
||||
}),
|
||||
stop: async (unregister) => {
|
||||
unregister?.();
|
||||
},
|
||||
onStop: async () => {
|
||||
statusSink({
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
17
openclaw/extensions/googlechat/src/group-policy.ts
Normal file
17
openclaw/extensions/googlechat/src/group-policy.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
||||
|
||||
type GoogleChatGroupContext = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
groupId?: string | null;
|
||||
};
|
||||
|
||||
export function resolveGoogleChatGroupRequireMention(params: GoogleChatGroupContext): boolean {
|
||||
return resolveChannelGroupRequireMention({
|
||||
cfg: params.cfg,
|
||||
channel: "googlechat",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
360
openclaw/extensions/googlechat/src/monitor-access.test.ts
Normal file
360
openclaw/extensions/googlechat/src/monitor-access.test.ts
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const createChannelPairingController = vi.hoisted(() => vi.fn());
|
||||
const evaluateGroupRouteAccessForPolicy = vi.hoisted(() => vi.fn());
|
||||
const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn());
|
||||
const resolveAllowlistProviderRuntimeGroupPolicy = vi.hoisted(() => vi.fn());
|
||||
const resolveDefaultGroupPolicy = vi.hoisted(() => vi.fn());
|
||||
const resolveDmGroupAccessWithLists = vi.hoisted(() => vi.fn());
|
||||
const resolveInboundMentionDecision = vi.hoisted(() => vi.fn());
|
||||
const resolveSenderScopedGroupPolicy = vi.hoisted(() => vi.fn());
|
||||
const warnMissingProviderGroupPolicyFallbackOnce = vi.hoisted(() => vi.fn());
|
||||
const sendGoogleChatMessage = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/channel-inbound", () => ({
|
||||
resolveInboundMentionDecision,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
GROUP_POLICY_BLOCKED_LABEL: { space: "space" },
|
||||
createChannelPairingController,
|
||||
evaluateGroupRouteAccessForPolicy,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveSenderScopedGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
}));
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
sendGoogleChatMessage,
|
||||
}));
|
||||
|
||||
function createCore() {
|
||||
return {
|
||||
channel: {
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: vi.fn(() => false),
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
||||
shouldHandleTextCommands: vi.fn(() => false),
|
||||
isControlCommandMessage: vi.fn(() => false),
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: vi.fn(() => false),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function primeCommonDefaults() {
|
||||
isDangerousNameMatchingEnabled.mockReturnValue(false);
|
||||
resolveDefaultGroupPolicy.mockReturnValue("allowlist");
|
||||
resolveAllowlistProviderRuntimeGroupPolicy.mockReturnValue({
|
||||
groupPolicy: "allowlist",
|
||||
providerMissingFallbackApplied: false,
|
||||
});
|
||||
resolveSenderScopedGroupPolicy.mockImplementation(({ groupPolicy }) => groupPolicy);
|
||||
evaluateGroupRouteAccessForPolicy.mockReturnValue({
|
||||
allowed: true,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce.mockReturnValue(undefined);
|
||||
}
|
||||
|
||||
const baseAccessConfig = {
|
||||
channels: { googlechat: {} },
|
||||
commands: { useAccessGroups: true },
|
||||
} as const;
|
||||
|
||||
const defaultSender = {
|
||||
senderId: "users/alice",
|
||||
senderName: "Alice",
|
||||
senderEmail: "alice@example.com",
|
||||
} as const;
|
||||
|
||||
let applyGoogleChatInboundAccessPolicy: typeof import("./monitor-access.js").applyGoogleChatInboundAccessPolicy;
|
||||
|
||||
function allowInboundGroupTraffic(options?: {
|
||||
effectiveGroupAllowFrom?: string[];
|
||||
effectiveWasMentioned?: boolean;
|
||||
}) {
|
||||
createChannelPairingController.mockReturnValue({
|
||||
readAllowFromStore: vi.fn(async () => []),
|
||||
issueChallenge: vi.fn(),
|
||||
});
|
||||
resolveDmGroupAccessWithLists.mockReturnValue({
|
||||
decision: "allow",
|
||||
effectiveAllowFrom: [],
|
||||
effectiveGroupAllowFrom: options?.effectiveGroupAllowFrom ?? ["users/alice"],
|
||||
});
|
||||
resolveInboundMentionDecision.mockReturnValue({
|
||||
shouldSkip: false,
|
||||
effectiveWasMentioned: options?.effectiveWasMentioned ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
async function applyInboundAccessPolicy(
|
||||
overrides: Partial<Parameters<typeof applyGoogleChatInboundAccessPolicy>[0]>,
|
||||
) {
|
||||
return applyGoogleChatInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {},
|
||||
} as never,
|
||||
config: baseAccessConfig as never,
|
||||
core: createCore() as never,
|
||||
space: { name: "spaces/AAA", displayName: "Team Room" } as never,
|
||||
message: { annotations: [] } as never,
|
||||
isGroup: true,
|
||||
rawBody: "hello team",
|
||||
logVerbose: vi.fn(),
|
||||
...defaultSender,
|
||||
...overrides,
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe("googlechat inbound access policy", () => {
|
||||
beforeAll(async () => {
|
||||
({ applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js"));
|
||||
});
|
||||
|
||||
it("issues a pairing challenge for unauthorized DMs in pairing mode", async () => {
|
||||
primeCommonDefaults();
|
||||
const issueChallenge = vi.fn(async ({ onCreated, sendPairingReply }) => {
|
||||
onCreated?.();
|
||||
await sendPairingReply("pairing text");
|
||||
});
|
||||
createChannelPairingController.mockReturnValue({
|
||||
readAllowFromStore: vi.fn(async () => []),
|
||||
issueChallenge,
|
||||
});
|
||||
resolveDmGroupAccessWithLists.mockReturnValue({
|
||||
decision: "pairing",
|
||||
reason: "pairing_required",
|
||||
effectiveAllowFrom: [],
|
||||
effectiveGroupAllowFrom: [],
|
||||
});
|
||||
sendGoogleChatMessage.mockResolvedValue({ ok: true });
|
||||
|
||||
const statusSink = vi.fn();
|
||||
const logVerbose = vi.fn();
|
||||
|
||||
await expect(
|
||||
applyGoogleChatInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
dm: { policy: "pairing" },
|
||||
},
|
||||
} as never,
|
||||
config: {
|
||||
channels: { googlechat: {} },
|
||||
} as never,
|
||||
core: createCore() as never,
|
||||
space: { name: "spaces/AAA", displayName: "DM" } as never,
|
||||
message: { annotations: [] } as never,
|
||||
isGroup: false,
|
||||
senderId: "users/abc",
|
||||
senderName: "Alice",
|
||||
senderEmail: "alice@example.com",
|
||||
rawBody: "hello",
|
||||
statusSink,
|
||||
logVerbose,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false });
|
||||
|
||||
expect(issueChallenge).toHaveBeenCalledTimes(1);
|
||||
expect(sendGoogleChatMessage).toHaveBeenCalledWith({
|
||||
account: expect.anything(),
|
||||
space: "spaces/AAA",
|
||||
text: "pairing text",
|
||||
});
|
||||
expect(statusSink).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lastOutboundAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows group traffic when sender and mention gates pass", async () => {
|
||||
primeCommonDefaults();
|
||||
allowInboundGroupTraffic();
|
||||
const core = createCore();
|
||||
core.channel.commands.shouldComputeCommandAuthorized.mockReturnValue(true);
|
||||
core.channel.commands.resolveCommandAuthorizedFromAuthorizers.mockReturnValue(true);
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
botUser: "users/app-bot",
|
||||
groups: {
|
||||
"spaces/AAA": {
|
||||
users: ["users/alice"],
|
||||
requireMention: true,
|
||||
systemPrompt: " group prompt ",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
core: core as never,
|
||||
message: {
|
||||
annotations: [
|
||||
{
|
||||
type: "USER_MENTION",
|
||||
userMention: { user: { name: "users/app-bot" } },
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
commandAuthorized: true,
|
||||
effectiveWasMentioned: true,
|
||||
groupSystemPrompt: "group prompt",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves allowlist group policy when a routed space has no sender allowlist", async () => {
|
||||
primeCommonDefaults();
|
||||
allowInboundGroupTraffic({
|
||||
effectiveGroupAllowFrom: [],
|
||||
effectiveWasMentioned: false,
|
||||
});
|
||||
resolveSenderScopedGroupPolicy.mockReturnValue("open");
|
||||
resolveSenderScopedGroupPolicy.mockClear();
|
||||
resolveDmGroupAccessWithLists.mockClear();
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
groups: {
|
||||
"spaces/AAA": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
commandAuthorized: undefined,
|
||||
effectiveWasMentioned: false,
|
||||
groupSystemPrompt: undefined,
|
||||
});
|
||||
|
||||
expect(resolveSenderScopedGroupPolicy).not.toHaveBeenCalled();
|
||||
expect(resolveDmGroupAccessWithLists).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops unauthorized group control commands", async () => {
|
||||
primeCommonDefaults();
|
||||
allowInboundGroupTraffic({
|
||||
effectiveGroupAllowFrom: [],
|
||||
effectiveWasMentioned: false,
|
||||
});
|
||||
const core = createCore();
|
||||
core.channel.commands.shouldComputeCommandAuthorized.mockReturnValue(true);
|
||||
core.channel.commands.resolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
|
||||
core.channel.commands.isControlCommandMessage.mockReturnValue(true);
|
||||
const logVerbose = vi.fn();
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
core: core as never,
|
||||
rawBody: "/admin",
|
||||
logVerbose,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false });
|
||||
|
||||
expect(logVerbose).toHaveBeenCalledWith("googlechat: drop control command from users/alice");
|
||||
});
|
||||
|
||||
it("does not match group policy by mutable space displayName when the stable id differs", async () => {
|
||||
primeCommonDefaults();
|
||||
allowInboundGroupTraffic();
|
||||
const logVerbose = vi.fn();
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
groups: {
|
||||
"Finance Ops": {
|
||||
users: ["users/alice"],
|
||||
requireMention: true,
|
||||
systemPrompt: "finance-only prompt",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
core: createCore() as never,
|
||||
space: { name: "spaces/BBB", displayName: "Finance Ops" } as never,
|
||||
message: {
|
||||
annotations: [
|
||||
{
|
||||
type: "USER_MENTION",
|
||||
userMention: { user: { name: "users/app" } },
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
rawBody: "show quarter close status",
|
||||
logVerbose,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false });
|
||||
|
||||
expect(logVerbose).toHaveBeenCalledWith(
|
||||
"Deprecated Google Chat group key detected: group routing now requires stable space ids (spaces/<spaceId>). Update channels.googlechat.groups keys: Finance Ops",
|
||||
);
|
||||
expect(logVerbose).toHaveBeenCalledWith(
|
||||
"drop group message (deprecated mutable group key matched, space=spaces/BBB)",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed instead of falling back to wildcard when a deprecated room key matches", async () => {
|
||||
primeCommonDefaults();
|
||||
resolveAllowlistProviderRuntimeGroupPolicy.mockReturnValue({
|
||||
groupPolicy: "open",
|
||||
providerMissingFallbackApplied: false,
|
||||
});
|
||||
allowInboundGroupTraffic();
|
||||
const logVerbose = vi.fn();
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"*": {
|
||||
users: ["users/alice"],
|
||||
},
|
||||
"Finance Ops": {
|
||||
enabled: false,
|
||||
users: ["users/bob"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
core: createCore() as never,
|
||||
space: { name: "spaces/BBB", displayName: "Finance Ops" } as never,
|
||||
rawBody: "show quarter close status",
|
||||
logVerbose,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false });
|
||||
|
||||
expect(logVerbose).toHaveBeenCalledWith(
|
||||
"drop group message (deprecated mutable group key matched, space=spaces/BBB)",
|
||||
);
|
||||
});
|
||||
});
|
||||
377
openclaw/extensions/googlechat/src/monitor-access.ts
Normal file
377
openclaw/extensions/googlechat/src/monitor-access.ts
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
createChannelPairingController,
|
||||
evaluateGroupRouteAccessForPolicy,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveSenderScopedGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
type OpenClawConfig,
|
||||
} from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { sendGoogleChatMessage } from "./api.js";
|
||||
import type { GoogleChatCoreRuntime } from "./monitor-types.js";
|
||||
import { isSenderAllowed } from "./sender-allow.js";
|
||||
import type { GoogleChatAnnotation, GoogleChatMessage, GoogleChatSpace } from "./types.js";
|
||||
|
||||
function normalizeUserId(raw?: string | null): string {
|
||||
const trimmed = normalizeOptionalString(raw) ?? "";
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, ""));
|
||||
}
|
||||
|
||||
export { isSenderAllowed } from "./sender-allow.js";
|
||||
|
||||
type GoogleChatGroupEntry = {
|
||||
requireMention?: boolean;
|
||||
enabled?: boolean;
|
||||
users?: Array<string | number>;
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
function resolveGroupConfig(params: {
|
||||
groupId: string;
|
||||
groupName?: string | null;
|
||||
groups?: Record<string, GoogleChatGroupEntry>;
|
||||
}) {
|
||||
const { groupId, groupName, groups } = params;
|
||||
const entries = groups ?? {};
|
||||
const keys = Object.keys(entries);
|
||||
if (keys.length === 0) {
|
||||
return { entry: undefined, allowlistConfigured: false, deprecatedNameMatch: false };
|
||||
}
|
||||
const entry = entries[groupId];
|
||||
const normalizedGroupName = normalizeLowercaseStringOrEmpty(groupName ?? "");
|
||||
const deprecatedNameMatch =
|
||||
!entry &&
|
||||
Boolean(
|
||||
groupName &&
|
||||
keys.some((key) => {
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed || trimmed === "*" || /^spaces\//i.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
trimmed === groupName || normalizeLowercaseStringOrEmpty(trimmed) === normalizedGroupName
|
||||
);
|
||||
}),
|
||||
);
|
||||
const fallback = entries["*"];
|
||||
return {
|
||||
entry: deprecatedNameMatch ? undefined : (entry ?? fallback),
|
||||
allowlistConfigured: true,
|
||||
fallback,
|
||||
deprecatedNameMatch,
|
||||
};
|
||||
}
|
||||
|
||||
function extractMentionInfo(annotations: GoogleChatAnnotation[], botUser?: string | null) {
|
||||
const mentionAnnotations = annotations.filter((entry) => entry.type === "USER_MENTION");
|
||||
const hasAnyMention = mentionAnnotations.length > 0;
|
||||
const botTargets = new Set(["users/app", botUser?.trim()].filter(Boolean) as string[]);
|
||||
const wasMentioned = mentionAnnotations.some((entry) => {
|
||||
const userName = entry.userMention?.user?.name;
|
||||
if (!userName) {
|
||||
return false;
|
||||
}
|
||||
if (botTargets.has(userName)) {
|
||||
return true;
|
||||
}
|
||||
return normalizeUserId(userName) === "app";
|
||||
});
|
||||
return { hasAnyMention, wasMentioned };
|
||||
}
|
||||
|
||||
const warnedDeprecatedUsersEmailAllowFrom = new Set<string>();
|
||||
const warnedMutableGroupKeys = new Set<string>();
|
||||
|
||||
function warnDeprecatedUsersEmailEntries(logVerbose: (message: string) => void, entries: string[]) {
|
||||
const deprecated = entries
|
||||
.map((v) => normalizeOptionalString(v))
|
||||
.filter((v): v is string => Boolean(v))
|
||||
.filter((v) => /^users\/.+@.+/i.test(v));
|
||||
if (deprecated.length === 0) {
|
||||
return;
|
||||
}
|
||||
const key = deprecated
|
||||
.map((v) => normalizeLowercaseStringOrEmpty(v))
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
.join(",");
|
||||
if (warnedDeprecatedUsersEmailAllowFrom.has(key)) {
|
||||
return;
|
||||
}
|
||||
warnedDeprecatedUsersEmailAllowFrom.add(key);
|
||||
logVerbose(
|
||||
`Deprecated allowFrom entry detected: "users/<email>" is no longer treated as an email allowlist. Use raw email (alice@example.com) or immutable user id (users/<id>). entries=${deprecated.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function warnMutableGroupKeysConfigured(
|
||||
logVerbose: (message: string) => void,
|
||||
groups?: Record<string, GoogleChatGroupEntry>,
|
||||
) {
|
||||
const mutableKeys = Object.keys(groups ?? {})
|
||||
.map((key) => key.trim())
|
||||
.filter((key) => key && key !== "*" && !/^spaces\//i.test(key));
|
||||
if (mutableKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
const warningKey = mutableKeys
|
||||
.map((key) => normalizeLowercaseStringOrEmpty(key))
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
.join(",");
|
||||
if (warnedMutableGroupKeys.has(warningKey)) {
|
||||
return;
|
||||
}
|
||||
warnedMutableGroupKeys.add(warningKey);
|
||||
logVerbose(
|
||||
`Deprecated Google Chat group key detected: group routing now requires stable space ids (spaces/<spaceId>). Update channels.googlechat.groups keys: ${mutableKeys.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function applyGoogleChatInboundAccessPolicy(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
config: OpenClawConfig;
|
||||
core: GoogleChatCoreRuntime;
|
||||
space: GoogleChatSpace;
|
||||
message: GoogleChatMessage;
|
||||
isGroup: boolean;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
senderEmail?: string;
|
||||
rawBody: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
logVerbose: (message: string) => void;
|
||||
}): Promise<
|
||||
| {
|
||||
ok: true;
|
||||
commandAuthorized: boolean | undefined;
|
||||
effectiveWasMentioned: boolean | undefined;
|
||||
groupSystemPrompt: string | undefined;
|
||||
}
|
||||
| { ok: false }
|
||||
> {
|
||||
const {
|
||||
account,
|
||||
config,
|
||||
core,
|
||||
space,
|
||||
message,
|
||||
isGroup,
|
||||
senderId,
|
||||
senderName,
|
||||
senderEmail,
|
||||
rawBody,
|
||||
statusSink,
|
||||
logVerbose,
|
||||
} = params;
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
|
||||
const spaceId = space.name ?? "";
|
||||
const pairing = createChannelPairingController({
|
||||
core,
|
||||
channel: "googlechat",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
|
||||
const { groupPolicy, providerMissingFallbackApplied } =
|
||||
resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: config.channels?.googlechat !== undefined,
|
||||
groupPolicy: account.config.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied,
|
||||
providerKey: "googlechat",
|
||||
accountId: account.accountId,
|
||||
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.space,
|
||||
log: logVerbose,
|
||||
});
|
||||
warnMutableGroupKeysConfigured(logVerbose, account.config.groups ?? undefined);
|
||||
const groupConfigResolved = resolveGroupConfig({
|
||||
groupId: spaceId,
|
||||
groupName: space.displayName ?? null,
|
||||
groups: account.config.groups ?? undefined,
|
||||
});
|
||||
const groupEntry = groupConfigResolved.entry;
|
||||
const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? [];
|
||||
let effectiveWasMentioned: boolean | undefined;
|
||||
|
||||
if (isGroup) {
|
||||
if (groupConfigResolved.deprecatedNameMatch) {
|
||||
logVerbose(`drop group message (deprecated mutable group key matched, space=${spaceId})`);
|
||||
return { ok: false };
|
||||
}
|
||||
const groupAllowlistConfigured = groupConfigResolved.allowlistConfigured;
|
||||
const routeAccess = evaluateGroupRouteAccessForPolicy({
|
||||
groupPolicy,
|
||||
routeAllowlistConfigured: groupAllowlistConfigured,
|
||||
routeMatched: Boolean(groupEntry),
|
||||
routeEnabled: groupEntry?.enabled !== false,
|
||||
});
|
||||
if (!routeAccess.allowed) {
|
||||
if (routeAccess.reason === "disabled") {
|
||||
logVerbose(`drop group message (groupPolicy=disabled, space=${spaceId})`);
|
||||
} else if (routeAccess.reason === "empty_allowlist") {
|
||||
logVerbose(`drop group message (groupPolicy=allowlist, no allowlist, space=${spaceId})`);
|
||||
} else if (routeAccess.reason === "route_not_allowlisted") {
|
||||
logVerbose(`drop group message (not allowlisted, space=${spaceId})`);
|
||||
} else if (routeAccess.reason === "route_disabled") {
|
||||
logVerbose(`drop group message (space disabled, space=${spaceId})`);
|
||||
}
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (groupUsers.length > 0) {
|
||||
const normalizedGroupUsers = groupUsers.map((v) => String(v));
|
||||
warnDeprecatedUsersEmailEntries(logVerbose, normalizedGroupUsers);
|
||||
const ok = isSenderAllowed(senderId, senderEmail, normalizedGroupUsers, allowNameMatching);
|
||||
if (!ok) {
|
||||
logVerbose(`drop group message (sender not allowed, ${senderId})`);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dmPolicy = account.config.dm?.policy ?? "pairing";
|
||||
const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v));
|
||||
const normalizedGroupUsers = groupUsers.map((v) => String(v));
|
||||
const senderGroupPolicy =
|
||||
groupConfigResolved.allowlistConfigured && normalizedGroupUsers.length === 0
|
||||
? groupPolicy
|
||||
: resolveSenderScopedGroupPolicy({
|
||||
groupPolicy,
|
||||
groupAllowFrom: normalizedGroupUsers,
|
||||
});
|
||||
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
|
||||
const storeAllowFrom =
|
||||
!isGroup && dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeAuth)
|
||||
? await pairing.readAllowFromStore().catch(() => [])
|
||||
: [];
|
||||
const access = resolveDmGroupAccessWithLists({
|
||||
isGroup,
|
||||
dmPolicy,
|
||||
groupPolicy: senderGroupPolicy,
|
||||
allowFrom: configAllowFrom,
|
||||
groupAllowFrom: normalizedGroupUsers,
|
||||
storeAllowFrom,
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
isSenderAllowed(senderId, senderEmail, allowFrom, allowNameMatching),
|
||||
});
|
||||
const effectiveAllowFrom = access.effectiveAllowFrom;
|
||||
const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom;
|
||||
warnDeprecatedUsersEmailEntries(logVerbose, effectiveAllowFrom);
|
||||
const commandAllowFrom = isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom;
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = isSenderAllowed(
|
||||
senderId,
|
||||
senderEmail,
|
||||
commandAllowFrom,
|
||||
allowNameMatching,
|
||||
);
|
||||
const commandAuthorized = shouldComputeAuth
|
||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
],
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (isGroup) {
|
||||
const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true;
|
||||
const annotations = message.annotations ?? [];
|
||||
const mentionInfo = extractMentionInfo(annotations, account.config.botUser);
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg: config,
|
||||
surface: "googlechat",
|
||||
});
|
||||
const mentionDecision = resolveInboundMentionDecision({
|
||||
facts: {
|
||||
canDetectMention: true,
|
||||
wasMentioned: mentionInfo.wasMentioned,
|
||||
hasAnyMention: mentionInfo.hasAnyMention,
|
||||
implicitMentionKinds: [],
|
||||
},
|
||||
policy: {
|
||||
isGroup: true,
|
||||
requireMention,
|
||||
allowTextCommands,
|
||||
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
|
||||
commandAuthorized: commandAuthorized === true,
|
||||
},
|
||||
});
|
||||
effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
|
||||
if (mentionDecision.shouldSkip) {
|
||||
logVerbose(`drop group message (mention required, space=${spaceId})`);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (isGroup && access.decision !== "allow") {
|
||||
logVerbose(
|
||||
`drop group message (sender policy blocked, reason=${access.reason}, space=${spaceId})`,
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (!isGroup) {
|
||||
if (account.config.dm?.enabled === false) {
|
||||
logVerbose(`Blocked Google Chat DM from ${senderId} (dmPolicy=disabled)`);
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (access.decision !== "allow") {
|
||||
if (access.decision === "pairing") {
|
||||
await pairing.issueChallenge({
|
||||
senderId,
|
||||
senderIdLine: `Your Google Chat user id: ${senderId}`,
|
||||
meta: { name: senderName || undefined, email: senderEmail },
|
||||
onCreated: () => {
|
||||
logVerbose(`googlechat pairing request sender=${senderId}`);
|
||||
},
|
||||
sendPairingReply: async (text) => {
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: spaceId,
|
||||
text,
|
||||
});
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
logVerbose(`pairing reply failed for ${senderId}: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
logVerbose(`Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`);
|
||||
}
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isGroup &&
|
||||
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
||||
commandAuthorized !== true
|
||||
) {
|
||||
logVerbose(`googlechat: drop control command from ${senderId}`);
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
commandAuthorized,
|
||||
effectiveWasMentioned,
|
||||
groupSystemPrompt: normalizeOptionalString(groupEntry?.systemPrompt),
|
||||
};
|
||||
}
|
||||
55
openclaw/extensions/googlechat/src/monitor-routing.ts
Normal file
55
openclaw/extensions/googlechat/src/monitor-routing.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { createWebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import { registerWebhookTargetWithPluginRoute } from "openclaw/plugin-sdk/webhook-targets";
|
||||
import type { WebhookTarget } from "./monitor-types.js";
|
||||
import { createGoogleChatWebhookRequestHandler } from "./monitor-webhook.js";
|
||||
import type { GoogleChatEvent } from "./types.js";
|
||||
|
||||
type ProcessGoogleChatEvent = (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
|
||||
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||
const webhookInFlightLimiter = createWebhookInFlightLimiter();
|
||||
|
||||
let processGoogleChatEvent: ProcessGoogleChatEvent = async () => {};
|
||||
|
||||
export function setGoogleChatWebhookEventProcessor(processEvent: ProcessGoogleChatEvent): void {
|
||||
processGoogleChatEvent = processEvent;
|
||||
}
|
||||
|
||||
const googleChatWebhookRequestHandler = createGoogleChatWebhookRequestHandler({
|
||||
webhookTargets,
|
||||
webhookInFlightLimiter,
|
||||
processEvent: async (event, target) => {
|
||||
await processGoogleChatEvent(event, target);
|
||||
},
|
||||
});
|
||||
|
||||
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
|
||||
return registerWebhookTargetWithPluginRoute({
|
||||
targetsByPath: webhookTargets,
|
||||
target,
|
||||
route: {
|
||||
auth: "plugin",
|
||||
match: "exact",
|
||||
pluginId: "googlechat",
|
||||
source: "googlechat-webhook",
|
||||
accountId: target.account.accountId,
|
||||
log: target.runtime.log,
|
||||
handler: async (req, res) => {
|
||||
const handled = await handleGoogleChatWebhookRequest(req, res);
|
||||
if (!handled && !res.headersSent) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Not Found");
|
||||
}
|
||||
},
|
||||
},
|
||||
}).unregister;
|
||||
}
|
||||
|
||||
export async function handleGoogleChatWebhookRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
return await googleChatWebhookRequestHandler(req, res);
|
||||
}
|
||||
33
openclaw/extensions/googlechat/src/monitor-types.ts
Normal file
33
openclaw/extensions/googlechat/src/monitor-types.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import type { GoogleChatAudienceType } from "./auth.js";
|
||||
import type { getGoogleChatRuntime } from "./runtime.js";
|
||||
|
||||
export type GoogleChatRuntimeEnv = {
|
||||
log?: (message: string) => void;
|
||||
error?: (message: string) => void;
|
||||
};
|
||||
|
||||
export type GoogleChatMonitorOptions = {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
config: OpenClawConfig;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
abortSignal: AbortSignal;
|
||||
webhookPath?: string;
|
||||
webhookUrl?: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
};
|
||||
|
||||
export type GoogleChatCoreRuntime = ReturnType<typeof getGoogleChatRuntime>;
|
||||
|
||||
export type WebhookTarget = {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
config: OpenClawConfig;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
core: GoogleChatCoreRuntime;
|
||||
path: string;
|
||||
audienceType?: GoogleChatAudienceType;
|
||||
audience?: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
mediaMaxMb: number;
|
||||
};
|
||||
187
openclaw/extensions/googlechat/src/monitor-webhook.test.ts
Normal file
187
openclaw/extensions/googlechat/src/monitor-webhook.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WebhookTarget } from "./monitor-types.js";
|
||||
import type { GoogleChatEvent } from "./types.js";
|
||||
|
||||
const readJsonWebhookBodyOrReject = vi.hoisted(() => vi.fn());
|
||||
const resolveWebhookTargetWithAuthOrReject = vi.hoisted(() => vi.fn());
|
||||
const withResolvedWebhookRequestPipeline = vi.hoisted(() => vi.fn());
|
||||
const verifyGoogleChatRequest = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/webhook-request-guards", () => ({
|
||||
readJsonWebhookBodyOrReject,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/webhook-targets", () => ({
|
||||
resolveWebhookTargetWithAuthOrReject,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
}));
|
||||
|
||||
vi.mock("./auth.js", () => ({
|
||||
verifyGoogleChatRequest,
|
||||
}));
|
||||
|
||||
type ProcessEventFn = (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
|
||||
let createGoogleChatWebhookRequestHandler: typeof import("./monitor-webhook.js").createGoogleChatWebhookRequestHandler;
|
||||
|
||||
function createRequest(authorization?: string): IncomingMessage {
|
||||
return {
|
||||
method: "POST",
|
||||
url: "/googlechat",
|
||||
headers: {
|
||||
authorization: authorization ?? "",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
} as IncomingMessage;
|
||||
}
|
||||
|
||||
function createResponse() {
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
headers: {} as Record<string, string>,
|
||||
body: "",
|
||||
setHeader: (name: string, value: string) => {
|
||||
res.headers[name] = value;
|
||||
},
|
||||
end: (payload?: string) => {
|
||||
res.body = payload ?? "";
|
||||
return res;
|
||||
},
|
||||
} as ServerResponse & { headers: Record<string, string>; body: string };
|
||||
return res;
|
||||
}
|
||||
|
||||
function installSimplePipeline(targets: unknown[]) {
|
||||
withResolvedWebhookRequestPipeline.mockImplementation(
|
||||
async ({
|
||||
handle,
|
||||
req,
|
||||
res,
|
||||
}: {
|
||||
handle: (input: {
|
||||
targets: unknown[];
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
}) => Promise<unknown>;
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
}) =>
|
||||
await handle({
|
||||
targets,
|
||||
req,
|
||||
res,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function runWebhookHandler(options?: {
|
||||
processEvent?: ProcessEventFn;
|
||||
authorization?: string;
|
||||
}) {
|
||||
const processEvent: ProcessEventFn =
|
||||
options?.processEvent ?? (vi.fn(async () => {}) as ProcessEventFn);
|
||||
const handler = createGoogleChatWebhookRequestHandler({
|
||||
webhookTargets: new Map(),
|
||||
webhookInFlightLimiter: {} as never,
|
||||
processEvent,
|
||||
});
|
||||
const req = createRequest(options?.authorization);
|
||||
const res = createResponse();
|
||||
await expect(handler(req, res)).resolves.toBe(true);
|
||||
return { processEvent, res };
|
||||
}
|
||||
|
||||
describe("googlechat monitor webhook", () => {
|
||||
beforeAll(async () => {
|
||||
({ createGoogleChatWebhookRequestHandler } = await import("./monitor-webhook.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("accepts add-on payloads that carry systemIdToken in the body", async () => {
|
||||
installSimplePipeline([
|
||||
{
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: { appPrincipal: "chat-app" },
|
||||
},
|
||||
runtime: { error: vi.fn() },
|
||||
statusSink: vi.fn(),
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
]);
|
||||
readJsonWebhookBodyOrReject.mockResolvedValue({
|
||||
ok: true,
|
||||
value: {
|
||||
commonEventObject: { hostApp: "CHAT" },
|
||||
authorizationEventObject: { systemIdToken: "addon-token" },
|
||||
chat: {
|
||||
eventTime: "2026-03-22T00:00:00.000Z",
|
||||
user: { name: "users/123" },
|
||||
messagePayload: {
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { name: "spaces/AAA/messages/1", text: "hello" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets }) => {
|
||||
for (const target of targets) {
|
||||
if (await isMatch(target)) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
verifyGoogleChatRequest.mockResolvedValue({ ok: true });
|
||||
const { processEvent, res } = await runWebhookHandler();
|
||||
|
||||
expect(verifyGoogleChatRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bearer: "addon-token",
|
||||
expectedAddOnPrincipal: "chat-app",
|
||||
}),
|
||||
);
|
||||
expect(processEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "MESSAGE",
|
||||
space: { name: "spaces/AAA" },
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("rejects missing add-on bearer tokens before dispatch", async () => {
|
||||
installSimplePipeline([
|
||||
{
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: { appPrincipal: "chat-app" },
|
||||
},
|
||||
runtime: { error: vi.fn() },
|
||||
},
|
||||
]);
|
||||
readJsonWebhookBodyOrReject.mockResolvedValue({
|
||||
ok: true,
|
||||
value: {
|
||||
commonEventObject: { hostApp: "CHAT" },
|
||||
chat: {
|
||||
messagePayload: {
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { name: "spaces/AAA/messages/1", text: "hello" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const { processEvent, res } = await runWebhookHandler();
|
||||
|
||||
expect(processEvent).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body).toBe("unauthorized");
|
||||
});
|
||||
});
|
||||
223
openclaw/extensions/googlechat/src/monitor-webhook.ts
Normal file
223
openclaw/extensions/googlechat/src/monitor-webhook.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { WebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import { readJsonWebhookBodyOrReject } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import {
|
||||
resolveWebhookTargetWithAuthOrReject,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
} from "openclaw/plugin-sdk/webhook-targets";
|
||||
import { verifyGoogleChatRequest } from "./auth.js";
|
||||
import type { WebhookTarget } from "./monitor-types.js";
|
||||
import type {
|
||||
GoogleChatEvent,
|
||||
GoogleChatMessage,
|
||||
GoogleChatSpace,
|
||||
GoogleChatUser,
|
||||
} from "./types.js";
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function extractBearerToken(header: unknown): string {
|
||||
const authHeader = Array.isArray(header)
|
||||
? typeof header[0] === "string"
|
||||
? header[0]
|
||||
: ""
|
||||
: typeof header === "string"
|
||||
? header
|
||||
: "";
|
||||
return normalizeLowercaseStringOrEmpty(authHeader).startsWith("bearer ")
|
||||
? authHeader.slice("bearer ".length).trim()
|
||||
: "";
|
||||
}
|
||||
|
||||
const ADD_ON_PREAUTH_MAX_BYTES = 16 * 1024;
|
||||
const ADD_ON_PREAUTH_TIMEOUT_MS = 3_000;
|
||||
|
||||
type ParsedGoogleChatInboundPayload =
|
||||
| { ok: true; event: GoogleChatEvent; addOnBearerToken: string }
|
||||
| { ok: false };
|
||||
type ParsedGoogleChatInboundSuccess = Extract<ParsedGoogleChatInboundPayload, { ok: true }>;
|
||||
|
||||
function parseGoogleChatInboundPayload(
|
||||
raw: unknown,
|
||||
res: ServerResponse,
|
||||
): ParsedGoogleChatInboundPayload {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
let eventPayload = raw;
|
||||
let addOnBearerToken = "";
|
||||
|
||||
// Transform Google Workspace Add-on format to standard Chat API format.
|
||||
const rawObj = raw as {
|
||||
commonEventObject?: { hostApp?: string };
|
||||
chat?: {
|
||||
messagePayload?: { space?: GoogleChatSpace; message?: GoogleChatMessage };
|
||||
user?: GoogleChatUser;
|
||||
eventTime?: string;
|
||||
};
|
||||
authorizationEventObject?: { systemIdToken?: string };
|
||||
};
|
||||
|
||||
if (rawObj.commonEventObject?.hostApp === "CHAT" && rawObj.chat?.messagePayload) {
|
||||
const chat = rawObj.chat;
|
||||
const messagePayload = chat.messagePayload;
|
||||
eventPayload = {
|
||||
type: "MESSAGE",
|
||||
space: messagePayload?.space,
|
||||
message: messagePayload?.message,
|
||||
user: chat.user,
|
||||
eventTime: chat.eventTime,
|
||||
};
|
||||
addOnBearerToken =
|
||||
typeof rawObj.authorizationEventObject?.systemIdToken === "string"
|
||||
? rawObj.authorizationEventObject.systemIdToken.trim()
|
||||
: "";
|
||||
}
|
||||
|
||||
const event = eventPayload as GoogleChatEvent;
|
||||
const eventType = event.type ?? (eventPayload as { eventType?: string }).eventType;
|
||||
if (typeof eventType !== "string") {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (!event.space || typeof event.space !== "object" || Array.isArray(event.space)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (eventType === "MESSAGE") {
|
||||
if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, event, addOnBearerToken };
|
||||
}
|
||||
|
||||
export function createGoogleChatWebhookRequestHandler(params: {
|
||||
webhookTargets: Map<string, WebhookTarget[]>;
|
||||
webhookInFlightLimiter: WebhookInFlightLimiter;
|
||||
processEvent: (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
|
||||
}): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
|
||||
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
|
||||
return await withResolvedWebhookRequestPipeline({
|
||||
req,
|
||||
res,
|
||||
targetsByPath: params.webhookTargets,
|
||||
allowMethods: ["POST"],
|
||||
requireJsonContentType: true,
|
||||
inFlightLimiter: params.webhookInFlightLimiter,
|
||||
handle: async ({ targets }) => {
|
||||
const headerBearer = extractBearerToken(req.headers.authorization);
|
||||
let selectedTarget: WebhookTarget | null = null;
|
||||
let parsedEvent: GoogleChatEvent | null = null;
|
||||
const readAndParseEvent = async (
|
||||
profile: "pre-auth" | "post-auth",
|
||||
): Promise<ParsedGoogleChatInboundSuccess | null> => {
|
||||
const body = await readJsonWebhookBodyOrReject({
|
||||
req,
|
||||
res,
|
||||
profile,
|
||||
...(profile === "pre-auth"
|
||||
? {
|
||||
maxBytes: ADD_ON_PREAUTH_MAX_BYTES,
|
||||
timeoutMs: ADD_ON_PREAUTH_TIMEOUT_MS,
|
||||
}
|
||||
: {}),
|
||||
emptyObjectOnEmpty: false,
|
||||
invalidJsonMessage: "invalid payload",
|
||||
});
|
||||
if (!body.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseGoogleChatInboundPayload(body.value, res);
|
||||
return parsed.ok ? parsed : null;
|
||||
};
|
||||
|
||||
if (headerBearer) {
|
||||
selectedTarget = await resolveWebhookTargetWithAuthOrReject({
|
||||
targets,
|
||||
res,
|
||||
isMatch: async (target) => {
|
||||
const verification = await verifyGoogleChatRequest({
|
||||
bearer: headerBearer,
|
||||
audienceType: target.audienceType,
|
||||
audience: target.audience,
|
||||
expectedAddOnPrincipal: target.account.config.appPrincipal,
|
||||
});
|
||||
return verification.ok;
|
||||
},
|
||||
});
|
||||
if (!selectedTarget) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsed = await readAndParseEvent("post-auth");
|
||||
if (!parsed) {
|
||||
return true;
|
||||
}
|
||||
parsedEvent = parsed.event;
|
||||
} else {
|
||||
const parsed = await readAndParseEvent("pre-auth");
|
||||
if (!parsed) {
|
||||
return true;
|
||||
}
|
||||
parsedEvent = parsed.event;
|
||||
|
||||
if (!parsed.addOnBearerToken) {
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
selectedTarget = await resolveWebhookTargetWithAuthOrReject({
|
||||
targets,
|
||||
res,
|
||||
isMatch: async (target) => {
|
||||
const verification = await verifyGoogleChatRequest({
|
||||
bearer: parsed.addOnBearerToken,
|
||||
audienceType: target.audienceType,
|
||||
audience: target.audience,
|
||||
expectedAddOnPrincipal: target.account.config.appPrincipal,
|
||||
});
|
||||
return verification.ok;
|
||||
},
|
||||
});
|
||||
if (!selectedTarget) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedTarget || !parsedEvent) {
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
const dispatchTarget = selectedTarget;
|
||||
dispatchTarget.statusSink?.({ lastInboundAt: Date.now() });
|
||||
params.processEvent(parsedEvent, dispatchTarget).catch((err) => {
|
||||
dispatchTarget.runtime.error?.(
|
||||
`[${dispatchTarget.account.accountId}] Google Chat webhook failed: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end("{}");
|
||||
return true;
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
517
openclaw/extensions/googlechat/src/monitor.ts
Normal file
517
openclaw/extensions/googlechat/src/monitor.ts
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
import {
|
||||
deliverTextOrMediaReply,
|
||||
resolveSendableOutboundReplyParts,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
createChannelReplyPipeline,
|
||||
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
||||
resolveWebhookPath,
|
||||
} from "../runtime-api.js";
|
||||
import { type ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import {
|
||||
downloadGoogleChatMedia,
|
||||
deleteGoogleChatMessage,
|
||||
sendGoogleChatMessage,
|
||||
uploadGoogleChatAttachment,
|
||||
updateGoogleChatMessage,
|
||||
} from "./api.js";
|
||||
import { type GoogleChatAudienceType } from "./auth.js";
|
||||
import { applyGoogleChatInboundAccessPolicy, isSenderAllowed } from "./monitor-access.js";
|
||||
import {
|
||||
handleGoogleChatWebhookRequest,
|
||||
registerGoogleChatWebhookTarget,
|
||||
setGoogleChatWebhookEventProcessor,
|
||||
} from "./monitor-routing.js";
|
||||
import type {
|
||||
GoogleChatCoreRuntime,
|
||||
GoogleChatMonitorOptions,
|
||||
GoogleChatRuntimeEnv,
|
||||
WebhookTarget,
|
||||
} from "./monitor-types.js";
|
||||
import { getGoogleChatRuntime } from "./runtime.js";
|
||||
import type { GoogleChatAttachment, GoogleChatEvent } from "./types.js";
|
||||
export type { GoogleChatMonitorOptions, GoogleChatRuntimeEnv } from "./monitor-types.js";
|
||||
export {
|
||||
handleGoogleChatWebhookRequest,
|
||||
registerGoogleChatWebhookTarget,
|
||||
} from "./monitor-routing.js";
|
||||
export { isSenderAllowed };
|
||||
|
||||
setGoogleChatWebhookEventProcessor(processGoogleChatEvent);
|
||||
|
||||
function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
runtime.log?.(`[googlechat] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
|
||||
const normalized = normalizeOptionalLowercaseString(value);
|
||||
if (normalized === "app-url" || normalized === "app_url" || normalized === "app") {
|
||||
return "app-url";
|
||||
}
|
||||
if (
|
||||
normalized === "project-number" ||
|
||||
normalized === "project_number" ||
|
||||
normalized === "project"
|
||||
) {
|
||||
return "project-number";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) {
|
||||
const eventType = event.type ?? (event as { eventType?: string }).eventType;
|
||||
if (eventType !== "MESSAGE") {
|
||||
return;
|
||||
}
|
||||
if (!event.message || !event.space) {
|
||||
return;
|
||||
}
|
||||
|
||||
await processMessageWithPipeline({
|
||||
event,
|
||||
account: target.account,
|
||||
config: target.config,
|
||||
runtime: target.runtime,
|
||||
core: target.core,
|
||||
statusSink: target.statusSink,
|
||||
mediaMaxMb: target.mediaMaxMb,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve bot display name with fallback chain:
|
||||
* 1. Account config name
|
||||
* 2. Agent name from config
|
||||
* 3. "OpenClaw" as generic fallback
|
||||
*/
|
||||
function resolveBotDisplayName(params: {
|
||||
accountName?: string;
|
||||
agentId: string;
|
||||
config: OpenClawConfig;
|
||||
}): string {
|
||||
const { accountName, agentId, config } = params;
|
||||
if (accountName?.trim()) {
|
||||
return accountName.trim();
|
||||
}
|
||||
const agent = config.agents?.list?.find((a) => a.id === agentId);
|
||||
if (agent?.name?.trim()) {
|
||||
return agent.name.trim();
|
||||
}
|
||||
return "OpenClaw";
|
||||
}
|
||||
|
||||
async function processMessageWithPipeline(params: {
|
||||
event: GoogleChatEvent;
|
||||
account: ResolvedGoogleChatAccount;
|
||||
config: OpenClawConfig;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
core: GoogleChatCoreRuntime;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
mediaMaxMb: number;
|
||||
}): Promise<void> {
|
||||
const { event, account, config, runtime, core, statusSink, mediaMaxMb } = params;
|
||||
const space = event.space;
|
||||
const message = event.message;
|
||||
if (!space || !message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spaceId = space.name ?? "";
|
||||
if (!spaceId) {
|
||||
return;
|
||||
}
|
||||
const spaceType = (space.type ?? "").toUpperCase();
|
||||
const isGroup = spaceType !== "DM";
|
||||
const sender = message.sender ?? event.user;
|
||||
const senderId = sender?.name ?? "";
|
||||
const senderName = sender?.displayName ?? "";
|
||||
const senderEmail = sender?.email ?? undefined;
|
||||
|
||||
const allowBots = account.config.allowBots === true;
|
||||
if (!allowBots) {
|
||||
if (sender?.type?.toUpperCase() === "BOT") {
|
||||
logVerbose(core, runtime, `skip bot-authored message (${senderId || "unknown"})`);
|
||||
return;
|
||||
}
|
||||
if (senderId === "users/app") {
|
||||
logVerbose(core, runtime, "skip app-authored message");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const messageText = (message.argumentText ?? message.text ?? "").trim();
|
||||
const attachments = message.attachment ?? [];
|
||||
const hasMedia = attachments.length > 0;
|
||||
const rawBody = messageText || (hasMedia ? "<media:attachment>" : "");
|
||||
if (!rawBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
const access = await applyGoogleChatInboundAccessPolicy({
|
||||
account,
|
||||
config,
|
||||
core,
|
||||
space,
|
||||
message,
|
||||
isGroup,
|
||||
senderId,
|
||||
senderName,
|
||||
senderEmail,
|
||||
rawBody,
|
||||
statusSink,
|
||||
logVerbose: (message) => logVerbose(core, runtime, message),
|
||||
});
|
||||
if (!access.ok) {
|
||||
return;
|
||||
}
|
||||
const { commandAuthorized, effectiveWasMentioned, groupSystemPrompt } = access;
|
||||
|
||||
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
||||
cfg: config,
|
||||
channel: "googlechat",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? ("group" as const) : ("direct" as const),
|
||||
id: spaceId,
|
||||
},
|
||||
runtime: core.channel,
|
||||
sessionStore: config.session?.store,
|
||||
});
|
||||
|
||||
let mediaPath: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
if (attachments.length > 0) {
|
||||
const first = attachments[0];
|
||||
const attachmentData = await downloadAttachment(first, account, mediaMaxMb, core);
|
||||
if (attachmentData) {
|
||||
mediaPath = attachmentData.path;
|
||||
mediaType = attachmentData.contentType;
|
||||
}
|
||||
}
|
||||
|
||||
const fromLabel = isGroup
|
||||
? space.displayName || `space:${spaceId}`
|
||||
: senderName || `user:${senderId}`;
|
||||
const { storePath, body } = buildEnvelope({
|
||||
channel: "Google Chat",
|
||||
from: fromLabel,
|
||||
timestamp: event.eventTime ? Date.parse(event.eventTime) : undefined,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
BodyForAgent: rawBody,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: `googlechat:${senderId}`,
|
||||
To: `googlechat:${spaceId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "channel" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
SenderUsername: senderEmail,
|
||||
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
Provider: "googlechat",
|
||||
Surface: "googlechat",
|
||||
MessageSid: message.name,
|
||||
MessageSidFull: message.name,
|
||||
ReplyToId: message.thread?.name,
|
||||
ReplyToIdFull: message.thread?.name,
|
||||
MediaPath: mediaPath,
|
||||
MediaType: mediaType,
|
||||
MediaUrl: mediaPath,
|
||||
GroupSpace: isGroup ? (space.displayName ?? undefined) : undefined,
|
||||
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
|
||||
OriginatingChannel: "googlechat",
|
||||
OriginatingTo: `googlechat:${spaceId}`,
|
||||
});
|
||||
|
||||
void core.channel.session
|
||||
.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error?.(`googlechat: failed updating session meta: ${String(err)}`);
|
||||
});
|
||||
|
||||
// Typing indicator setup
|
||||
// Note: Reaction mode requires user OAuth, not available with service account auth.
|
||||
// If reaction is configured, we fall back to message mode with a warning.
|
||||
let typingIndicator = account.config.typingIndicator ?? "message";
|
||||
if (typingIndicator === "reaction") {
|
||||
runtime.error?.(
|
||||
`[${account.accountId}] typingIndicator="reaction" requires user OAuth (not supported with service account). Falling back to "message" mode.`,
|
||||
);
|
||||
typingIndicator = "message";
|
||||
}
|
||||
let typingMessageName: string | undefined;
|
||||
|
||||
// Start typing indicator (message mode only, reaction mode not supported with app auth)
|
||||
if (typingIndicator === "message") {
|
||||
try {
|
||||
const botName = resolveBotDisplayName({
|
||||
accountName: account.config.name,
|
||||
agentId: route.agentId,
|
||||
config,
|
||||
});
|
||||
const result = await sendGoogleChatMessage({
|
||||
account,
|
||||
space: spaceId,
|
||||
text: `_${botName} is typing..._`,
|
||||
thread: message.thread?.name,
|
||||
});
|
||||
typingMessageName = result?.messageName;
|
||||
} catch (err) {
|
||||
runtime.error?.(`Failed sending typing message: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
|
||||
cfg: config,
|
||||
agentId: route.agentId,
|
||||
channel: "googlechat",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
...replyPipeline,
|
||||
deliver: async (payload) => {
|
||||
await deliverGoogleChatReply({
|
||||
payload,
|
||||
account,
|
||||
spaceId,
|
||||
runtime,
|
||||
core,
|
||||
config,
|
||||
statusSink,
|
||||
typingMessageName,
|
||||
});
|
||||
// Only use typing message for first delivery
|
||||
typingMessageName = undefined;
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(
|
||||
`[${account.accountId}] Google Chat ${info.kind} reply failed: ${String(err)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadAttachment(
|
||||
attachment: GoogleChatAttachment,
|
||||
account: ResolvedGoogleChatAccount,
|
||||
mediaMaxMb: number,
|
||||
core: GoogleChatCoreRuntime,
|
||||
): Promise<{ path: string; contentType?: string } | null> {
|
||||
const resourceName = attachment.attachmentDataRef?.resourceName;
|
||||
if (!resourceName) {
|
||||
return null;
|
||||
}
|
||||
const maxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
||||
const downloaded = await downloadGoogleChatMedia({ account, resourceName, maxBytes });
|
||||
const saved = await core.channel.media.saveMediaBuffer(
|
||||
downloaded.buffer,
|
||||
downloaded.contentType ?? attachment.contentType,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
attachment.contentName,
|
||||
);
|
||||
return { path: saved.path, contentType: saved.contentType };
|
||||
}
|
||||
|
||||
async function deliverGoogleChatReply(params: {
|
||||
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
|
||||
account: ResolvedGoogleChatAccount;
|
||||
spaceId: string;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
core: GoogleChatCoreRuntime;
|
||||
config: OpenClawConfig;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
typingMessageName?: string;
|
||||
}): Promise<void> {
|
||||
const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } =
|
||||
params;
|
||||
const reply = resolveSendableOutboundReplyParts(payload);
|
||||
const mediaCount = reply.mediaCount;
|
||||
const hasMedia = reply.hasMedia;
|
||||
const text = reply.text;
|
||||
let firstTextChunk = true;
|
||||
let suppressCaption = false;
|
||||
|
||||
if (hasMedia) {
|
||||
if (typingMessageName) {
|
||||
try {
|
||||
await deleteGoogleChatMessage({
|
||||
account,
|
||||
messageName: typingMessageName,
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
|
||||
const fallbackText = reply.hasText
|
||||
? text
|
||||
: mediaCount > 1
|
||||
? "Sent attachments."
|
||||
: "Sent attachment.";
|
||||
try {
|
||||
await updateGoogleChatMessage({
|
||||
account,
|
||||
messageName: typingMessageName,
|
||||
text: fallbackText,
|
||||
});
|
||||
suppressCaption = Boolean(text.trim());
|
||||
} catch (updateErr) {
|
||||
runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chunkLimit = account.config.textChunkLimit ?? 4000;
|
||||
const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId);
|
||||
await deliverTextOrMediaReply({
|
||||
payload,
|
||||
text: suppressCaption ? "" : reply.text,
|
||||
chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode),
|
||||
sendText: async (chunk) => {
|
||||
try {
|
||||
if (firstTextChunk && typingMessageName) {
|
||||
await updateGoogleChatMessage({
|
||||
account,
|
||||
messageName: typingMessageName,
|
||||
text: chunk,
|
||||
});
|
||||
} else {
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: spaceId,
|
||||
text: chunk,
|
||||
thread: payload.replyToId,
|
||||
});
|
||||
}
|
||||
firstTextChunk = false;
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error?.(`Google Chat message send failed: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
sendMedia: async ({ mediaUrl, caption }) => {
|
||||
try {
|
||||
const loaded = await core.channel.media.fetchRemoteMedia({
|
||||
url: mediaUrl,
|
||||
maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
|
||||
});
|
||||
const upload = await uploadAttachmentForReply({
|
||||
account,
|
||||
spaceId,
|
||||
buffer: loaded.buffer,
|
||||
contentType: loaded.contentType,
|
||||
filename: loaded.fileName ?? "attachment",
|
||||
});
|
||||
if (!upload.attachmentUploadToken) {
|
||||
throw new Error("missing attachment upload token");
|
||||
}
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: spaceId,
|
||||
text: caption,
|
||||
thread: payload.replyToId,
|
||||
attachments: [
|
||||
{ attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.fileName },
|
||||
],
|
||||
});
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error?.(`Google Chat attachment send failed: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadAttachmentForReply(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
spaceId: string;
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
filename: string;
|
||||
}) {
|
||||
const { account, spaceId, buffer, contentType, filename } = params;
|
||||
return await uploadGoogleChatAttachment({
|
||||
account,
|
||||
space: spaceId,
|
||||
filename,
|
||||
buffer,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
|
||||
export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): () => void {
|
||||
const core = getGoogleChatRuntime();
|
||||
const webhookPath = resolveWebhookPath({
|
||||
webhookPath: options.webhookPath,
|
||||
webhookUrl: options.webhookUrl,
|
||||
defaultPath: "/googlechat",
|
||||
});
|
||||
if (!webhookPath) {
|
||||
options.runtime.error?.(`[${options.account.accountId}] invalid webhook path`);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const audienceType = normalizeAudienceType(options.account.config.audienceType);
|
||||
const audience = options.account.config.audience?.trim();
|
||||
const mediaMaxMb = options.account.config.mediaMaxMb ?? 20;
|
||||
|
||||
const unregisterTarget = registerGoogleChatWebhookTarget({
|
||||
account: options.account,
|
||||
config: options.config,
|
||||
runtime: options.runtime,
|
||||
core,
|
||||
path: webhookPath,
|
||||
audienceType,
|
||||
audience,
|
||||
statusSink: options.statusSink,
|
||||
mediaMaxMb,
|
||||
});
|
||||
|
||||
return () => {
|
||||
unregisterTarget();
|
||||
};
|
||||
}
|
||||
|
||||
export async function startGoogleChatMonitor(
|
||||
params: GoogleChatMonitorOptions,
|
||||
): Promise<() => void> {
|
||||
return monitorGoogleChatProvider(params);
|
||||
}
|
||||
|
||||
export function resolveGoogleChatWebhookPath(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
}): string {
|
||||
return (
|
||||
resolveWebhookPath({
|
||||
webhookPath: params.account.config.webhookPath,
|
||||
webhookUrl: params.account.config.webhookUrl,
|
||||
defaultPath: "/googlechat",
|
||||
}) ?? "/googlechat"
|
||||
);
|
||||
}
|
||||
|
||||
export function computeGoogleChatMediaMaxMb(params: { account: ResolvedGoogleChatAccount }) {
|
||||
return params.account.config.mediaMaxMb ?? 20;
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createEmptyPluginRegistry } from "../../../src/plugins/registry-empty.js";
|
||||
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
||||
import { createMockServerResponse } from "../../../test/helpers/plugins/mock-http-response.js";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { verifyGoogleChatRequest } from "./auth.js";
|
||||
import {
|
||||
handleGoogleChatWebhookRequest,
|
||||
registerGoogleChatWebhookTarget,
|
||||
} from "./monitor-routing.js";
|
||||
|
||||
vi.mock("./auth.js", () => ({
|
||||
verifyGoogleChatRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
function createWebhookRequest(params: {
|
||||
authorization?: string;
|
||||
payload: unknown;
|
||||
path?: string;
|
||||
}): IncomingMessage {
|
||||
const req = new EventEmitter() as IncomingMessage & {
|
||||
destroyed?: boolean;
|
||||
destroy: (error?: Error) => IncomingMessage;
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => IncomingMessage;
|
||||
};
|
||||
req.method = "POST";
|
||||
req.url = params.path ?? "/googlechat";
|
||||
req.headers = {
|
||||
authorization: params.authorization ?? "",
|
||||
"content-type": "application/json",
|
||||
};
|
||||
req.destroyed = false;
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
req.destroy = () => {
|
||||
req.destroyed = true;
|
||||
return req;
|
||||
};
|
||||
|
||||
const originalOn = req.on.bind(req);
|
||||
let bodyScheduled = false;
|
||||
req.on = ((event: string, listener: (...args: unknown[]) => void) => {
|
||||
const result = originalOn(event, listener);
|
||||
if (!bodyScheduled && event === "data") {
|
||||
bodyScheduled = true;
|
||||
void Promise.resolve().then(() => {
|
||||
req.emit("data", Buffer.from(JSON.stringify(params.payload), "utf-8"));
|
||||
if (!req.destroyed) {
|
||||
req.emit("end");
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}) as IncomingMessage["on"];
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
function createHeaderOnlyWebhookRequest(params: {
|
||||
authorization?: string;
|
||||
path?: string;
|
||||
}): IncomingMessage {
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = "POST";
|
||||
req.url = params.path ?? "/googlechat";
|
||||
req.headers = {
|
||||
authorization: params.authorization ?? "",
|
||||
"content-type": "application/json",
|
||||
};
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
return req;
|
||||
}
|
||||
|
||||
const baseAccount = (accountId: string) =>
|
||||
({
|
||||
accountId,
|
||||
enabled: true,
|
||||
credentialSource: "none",
|
||||
config: {},
|
||||
}) as ResolvedGoogleChatAccount;
|
||||
|
||||
function registerTwoTargets() {
|
||||
const sinkA = vi.fn();
|
||||
const sinkB = vi.fn();
|
||||
const core = {} as PluginRuntime;
|
||||
const config = {} as OpenClawConfig;
|
||||
|
||||
const unregisterA = registerGoogleChatWebhookTarget({
|
||||
account: baseAccount("A"),
|
||||
config,
|
||||
runtime: {},
|
||||
core,
|
||||
path: "/googlechat",
|
||||
statusSink: sinkA,
|
||||
mediaMaxMb: 5,
|
||||
});
|
||||
const unregisterB = registerGoogleChatWebhookTarget({
|
||||
account: baseAccount("B"),
|
||||
config,
|
||||
runtime: {},
|
||||
core,
|
||||
path: "/googlechat",
|
||||
statusSink: sinkB,
|
||||
mediaMaxMb: 5,
|
||||
});
|
||||
|
||||
return {
|
||||
sinkA,
|
||||
sinkB,
|
||||
unregister: () => {
|
||||
unregisterA();
|
||||
unregisterB();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchWebhookRequest(req: IncomingMessage) {
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handleGoogleChatWebhookRequest(req, res);
|
||||
expect(handled).toBe(true);
|
||||
return res;
|
||||
}
|
||||
|
||||
async function expectVerifiedRoute(params: {
|
||||
request: IncomingMessage;
|
||||
expectedStatus: number;
|
||||
sinkA: ReturnType<typeof vi.fn>;
|
||||
sinkB: ReturnType<typeof vi.fn>;
|
||||
expectedSink: "none" | "A" | "B";
|
||||
}) {
|
||||
const res = await dispatchWebhookRequest(params.request);
|
||||
expect(res.statusCode).toBe(params.expectedStatus);
|
||||
const expectedCounts =
|
||||
params.expectedSink === "A" ? [1, 0] : params.expectedSink === "B" ? [0, 1] : [0, 0];
|
||||
expect(params.sinkA).toHaveBeenCalledTimes(expectedCounts[0]);
|
||||
expect(params.sinkB).toHaveBeenCalledTimes(expectedCounts[1]);
|
||||
}
|
||||
|
||||
function mockSecondVerifierSuccess() {
|
||||
vi.mocked(verifyGoogleChatRequest)
|
||||
.mockResolvedValueOnce({ ok: false, reason: "invalid" })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
}
|
||||
|
||||
describe("Google Chat webhook routing", () => {
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
});
|
||||
|
||||
it("rejects ambiguous routing when multiple targets on the same path verify successfully", async () => {
|
||||
vi.mocked(verifyGoogleChatRequest).mockResolvedValue({ ok: true });
|
||||
|
||||
const { sinkA, sinkB, unregister } = registerTwoTargets();
|
||||
|
||||
try {
|
||||
await expectVerifiedRoute({
|
||||
request: createWebhookRequest({
|
||||
authorization: "Bearer test-token",
|
||||
payload: { type: "ADDED_TO_SPACE", space: { name: "spaces/AAA" } },
|
||||
}),
|
||||
expectedStatus: 401,
|
||||
sinkA,
|
||||
sinkB,
|
||||
expectedSink: "none",
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes to the single verified target when earlier targets fail verification", async () => {
|
||||
mockSecondVerifierSuccess();
|
||||
|
||||
const { sinkA, sinkB, unregister } = registerTwoTargets();
|
||||
|
||||
try {
|
||||
await expectVerifiedRoute({
|
||||
request: createWebhookRequest({
|
||||
authorization: "Bearer test-token",
|
||||
payload: { type: "ADDED_TO_SPACE", space: { name: "spaces/BBB" } },
|
||||
}),
|
||||
expectedStatus: 200,
|
||||
sinkA,
|
||||
sinkB,
|
||||
expectedSink: "B",
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid bearer before attempting to read the body", async () => {
|
||||
vi.mocked(verifyGoogleChatRequest).mockResolvedValue({ ok: false, reason: "invalid" });
|
||||
const { unregister } = registerTwoTargets();
|
||||
|
||||
try {
|
||||
const req = createHeaderOnlyWebhookRequest({
|
||||
authorization: "Bearer invalid-token",
|
||||
});
|
||||
const onSpy = vi.spyOn(req, "on");
|
||||
const res = await dispatchWebhookRequest(req);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("supports add-on requests that provide systemIdToken in the body", async () => {
|
||||
mockSecondVerifierSuccess();
|
||||
const { sinkA, sinkB, unregister } = registerTwoTargets();
|
||||
|
||||
try {
|
||||
await expectVerifiedRoute({
|
||||
request: createWebhookRequest({
|
||||
payload: {
|
||||
commonEventObject: { hostApp: "CHAT" },
|
||||
authorizationEventObject: { systemIdToken: "addon-token" },
|
||||
chat: {
|
||||
eventTime: "2026-03-02T00:00:00.000Z",
|
||||
user: { name: "users/12345", displayName: "Test User" },
|
||||
messagePayload: {
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { text: "Hello from add-on" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
expectedStatus: 200,
|
||||
sinkA,
|
||||
sinkB,
|
||||
expectedSink: "B",
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
});
|
||||
9
openclaw/extensions/googlechat/src/runtime.ts
Normal file
9
openclaw/extensions/googlechat/src/runtime.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setGoogleChatRuntime, getRuntime: getGoogleChatRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "googlechat",
|
||||
errorMessage: "Google Chat runtime not initialized",
|
||||
});
|
||||
export { getGoogleChatRuntime, setGoogleChatRuntime };
|
||||
60
openclaw/extensions/googlechat/src/secret-contract.test.ts
Normal file
60
openclaw/extensions/googlechat/src/secret-contract.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/types.js";
|
||||
import { resolveSecretRefValues } from "../../../src/secrets/resolve.js";
|
||||
import {
|
||||
applyResolvedAssignments,
|
||||
createResolverContext,
|
||||
} from "../../../src/secrets/runtime-shared.js";
|
||||
import { collectRuntimeConfigAssignments } from "./secret-contract.js";
|
||||
|
||||
describe("googlechat secret contract", () => {
|
||||
it("resolves account serviceAccount SecretRefs for enabled accounts", async () => {
|
||||
const sourceConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
work: {
|
||||
enabled: true,
|
||||
serviceAccountRef: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "GOOGLECHAT_SERVICE_ACCOUNT",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
const resolvedConfig: OpenClawConfig = structuredClone(sourceConfig);
|
||||
const context = createResolverContext({
|
||||
sourceConfig,
|
||||
env: {
|
||||
GOOGLECHAT_SERVICE_ACCOUNT: '{"client_email":"bot@example.com"}',
|
||||
},
|
||||
});
|
||||
|
||||
collectRuntimeConfigAssignments({
|
||||
config: resolvedConfig,
|
||||
defaults: undefined,
|
||||
context,
|
||||
});
|
||||
|
||||
const resolved = await resolveSecretRefValues(
|
||||
context.assignments.map((assignment) => assignment.ref),
|
||||
{
|
||||
config: sourceConfig,
|
||||
env: context.env,
|
||||
cache: context.cache,
|
||||
},
|
||||
);
|
||||
applyResolvedAssignments({
|
||||
assignments: context.assignments,
|
||||
resolved,
|
||||
});
|
||||
|
||||
const workAccount = resolvedConfig.channels?.googlechat?.accounts?.work;
|
||||
expect(workAccount?.serviceAccount).toBe('{"client_email":"bot@example.com"}');
|
||||
expect(context.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
161
openclaw/extensions/googlechat/src/secret-contract.ts
Normal file
161
openclaw/extensions/googlechat/src/secret-contract.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import {
|
||||
getChannelSurface,
|
||||
hasOwnProperty,
|
||||
pushAssignment,
|
||||
pushInactiveSurfaceWarning,
|
||||
pushWarning,
|
||||
resolveChannelAccountSurface,
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
type SecretTargetRegistryEntry,
|
||||
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
|
||||
import { coerceSecretRef } from "openclaw/plugin-sdk/secret-ref-runtime";
|
||||
|
||||
type GoogleChatAccountLike = {
|
||||
serviceAccount?: unknown;
|
||||
serviceAccountRef?: unknown;
|
||||
accounts?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const secretTargetRegistryEntries = [
|
||||
{
|
||||
id: "channels.googlechat.accounts.*.serviceAccount",
|
||||
targetType: "channels.googlechat.serviceAccount",
|
||||
targetTypeAliases: ["channels.googlechat.accounts.*.serviceAccount"],
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.googlechat.accounts.*.serviceAccount",
|
||||
refPathPattern: "channels.googlechat.accounts.*.serviceAccountRef",
|
||||
secretShape: "sibling_ref",
|
||||
expectedResolvedValue: "string-or-object",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
accountIdPathSegmentIndex: 3,
|
||||
},
|
||||
{
|
||||
id: "channels.googlechat.serviceAccount",
|
||||
targetType: "channels.googlechat.serviceAccount",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.googlechat.serviceAccount",
|
||||
refPathPattern: "channels.googlechat.serviceAccountRef",
|
||||
secretShape: "sibling_ref",
|
||||
expectedResolvedValue: "string-or-object",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
] satisfies SecretTargetRegistryEntry[];
|
||||
|
||||
function resolveSecretInputRef(params: {
|
||||
value: unknown;
|
||||
refValue?: unknown;
|
||||
defaults?: SecretDefaults;
|
||||
}) {
|
||||
const explicitRef = coerceSecretRef(params.refValue, params.defaults);
|
||||
const inlineRef = explicitRef ? null : coerceSecretRef(params.value, params.defaults);
|
||||
return {
|
||||
explicitRef,
|
||||
inlineRef,
|
||||
ref: explicitRef ?? inlineRef,
|
||||
};
|
||||
}
|
||||
|
||||
function collectGoogleChatAccountAssignment(params: {
|
||||
target: GoogleChatAccountLike;
|
||||
path: string;
|
||||
defaults?: SecretDefaults;
|
||||
context: ResolverContext;
|
||||
active?: boolean;
|
||||
inactiveReason?: string;
|
||||
}): void {
|
||||
const { explicitRef, ref } = resolveSecretInputRef({
|
||||
value: params.target.serviceAccount,
|
||||
refValue: params.target.serviceAccountRef,
|
||||
defaults: params.defaults,
|
||||
});
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
if (params.active === false) {
|
||||
pushInactiveSurfaceWarning({
|
||||
context: params.context,
|
||||
path: `${params.path}.serviceAccount`,
|
||||
details: params.inactiveReason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
explicitRef &&
|
||||
params.target.serviceAccount !== undefined &&
|
||||
!coerceSecretRef(params.target.serviceAccount, params.defaults)
|
||||
) {
|
||||
pushWarning(params.context, {
|
||||
code: "SECRETS_REF_OVERRIDES_PLAINTEXT",
|
||||
path: params.path,
|
||||
message: `${params.path}: serviceAccountRef is set; runtime will ignore plaintext serviceAccount.`,
|
||||
});
|
||||
}
|
||||
pushAssignment(params.context, {
|
||||
ref,
|
||||
path: `${params.path}.serviceAccount`,
|
||||
expected: "string-or-object",
|
||||
apply: (value) => {
|
||||
params.target.serviceAccount = value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function collectRuntimeConfigAssignments(params: {
|
||||
config: { channels?: Record<string, unknown> };
|
||||
defaults?: SecretDefaults;
|
||||
context: ResolverContext;
|
||||
}): void {
|
||||
const resolved = getChannelSurface(params.config, "googlechat");
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const googleChat = resolved.channel as GoogleChatAccountLike;
|
||||
const surface = resolveChannelAccountSurface(googleChat as Record<string, unknown>);
|
||||
const topLevelServiceAccountActive = !surface.channelEnabled
|
||||
? false
|
||||
: !surface.hasExplicitAccounts
|
||||
? true
|
||||
: surface.accounts.some(
|
||||
({ account, enabled }) =>
|
||||
enabled &&
|
||||
!hasOwnProperty(account, "serviceAccount") &&
|
||||
!hasOwnProperty(account, "serviceAccountRef"),
|
||||
);
|
||||
collectGoogleChatAccountAssignment({
|
||||
target: googleChat,
|
||||
path: "channels.googlechat",
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
active: topLevelServiceAccountActive,
|
||||
inactiveReason: "no enabled account inherits this top-level Google Chat serviceAccount.",
|
||||
});
|
||||
if (!surface.hasExplicitAccounts) {
|
||||
return;
|
||||
}
|
||||
for (const { accountId, account, enabled } of surface.accounts) {
|
||||
if (
|
||||
!hasOwnProperty(account, "serviceAccount") &&
|
||||
!hasOwnProperty(account, "serviceAccountRef")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
collectGoogleChatAccountAssignment({
|
||||
target: account as GoogleChatAccountLike,
|
||||
path: `channels.googlechat.accounts.${accountId}`,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
active: enabled,
|
||||
inactiveReason: "Google Chat account is disabled.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const channelSecrets = {
|
||||
secretTargetRegistryEntries,
|
||||
collectRuntimeConfigAssignments,
|
||||
};
|
||||
48
openclaw/extensions/googlechat/src/sender-allow.ts
Normal file
48
openclaw/extensions/googlechat/src/sender-allow.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function normalizeUserId(raw?: string | null): string {
|
||||
const trimmed = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, ""));
|
||||
}
|
||||
|
||||
function isEmailLike(value: string): boolean {
|
||||
// Keep this intentionally loose; allowlists are user-provided config.
|
||||
return value.includes("@");
|
||||
}
|
||||
|
||||
export function isSenderAllowed(
|
||||
senderId: string,
|
||||
senderEmail: string | undefined,
|
||||
allowFrom: string[],
|
||||
allowNameMatching = false,
|
||||
) {
|
||||
if (allowFrom.includes("*")) {
|
||||
return true;
|
||||
}
|
||||
const normalizedSenderId = normalizeUserId(senderId);
|
||||
const normalizedEmail = normalizeLowercaseStringOrEmpty(senderEmail ?? "");
|
||||
return allowFrom.some((entry) => {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(entry);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Accept `googlechat:<id>` but treat `users/...` as an *ID* only (deprecated `users/<email>`).
|
||||
const withoutPrefix = normalized.replace(/^(googlechat|google-chat|gchat):/i, "");
|
||||
if (withoutPrefix.startsWith("users/")) {
|
||||
return normalizeUserId(withoutPrefix) === normalizedSenderId;
|
||||
}
|
||||
|
||||
// Raw email allowlist entries are a break-glass override.
|
||||
if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) {
|
||||
return withoutPrefix === normalizedEmail;
|
||||
}
|
||||
|
||||
return withoutPrefix.replace(/^users\//i, "") === normalizedSenderId;
|
||||
});
|
||||
}
|
||||
40
openclaw/extensions/googlechat/src/setup-core.ts
Normal file
40
openclaw/extensions/googlechat/src/setup-core.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import {
|
||||
createPatchedAccountSetupAdapter,
|
||||
createSetupInputPresenceValidator,
|
||||
} from "openclaw/plugin-sdk/setup-runtime";
|
||||
|
||||
const channel = "googlechat" as const;
|
||||
|
||||
export const googlechatSetupAdapter = createPatchedAccountSetupAdapter({
|
||||
channelKey: channel,
|
||||
validateInput: createSetupInputPresenceValidator({
|
||||
defaultAccountOnlyEnvError:
|
||||
"GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account.",
|
||||
whenNotUseEnv: [
|
||||
{
|
||||
someOf: ["token", "tokenFile"],
|
||||
message: "Google Chat requires --token (service account JSON) or --token-file.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
buildPatch: (input) => {
|
||||
const patch = input.useEnv
|
||||
? {}
|
||||
: input.tokenFile
|
||||
? { serviceAccountFile: input.tokenFile }
|
||||
: input.token
|
||||
? { serviceAccount: input.token }
|
||||
: {};
|
||||
const audienceType = input.audienceType?.trim();
|
||||
const audience = input.audience?.trim();
|
||||
const webhookPath = input.webhookPath?.trim();
|
||||
const webhookUrl = input.webhookUrl?.trim();
|
||||
return {
|
||||
...patch,
|
||||
...(audienceType ? { audienceType } : {}),
|
||||
...(audience ? { audience } : {}),
|
||||
...(webhookPath ? { webhookPath } : {}),
|
||||
...(webhookUrl ? { webhookUrl } : {}),
|
||||
};
|
||||
},
|
||||
});
|
||||
241
openclaw/extensions/googlechat/src/setup-surface.ts
Normal file
241
openclaw/extensions/googlechat/src/setup-surface.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import {
|
||||
addWildcardAllowFrom,
|
||||
applySetupAccountConfigPatch,
|
||||
createPromptParsedAllowFromForAccount,
|
||||
createStandardChannelSetupStatus,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
mergeAllowFromEntries,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
splitSetupEntries,
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizard,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { resolveDefaultGoogleChatAccountId, resolveGoogleChatAccount } from "./accounts.js";
|
||||
|
||||
const channel = "googlechat" as const;
|
||||
const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
|
||||
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
|
||||
const USE_ENV_FLAG = "__googlechatUseEnv";
|
||||
const AUTH_METHOD_FLAG = "__googlechatAuthMethod";
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeStringifiedOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return normalizeOptionalString(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
||||
return normalizeOptionalString(String(value));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const promptAllowFrom = createPromptParsedAllowFromForAccount({
|
||||
defaultAccountId: resolveDefaultGoogleChatAccountId,
|
||||
message: "Google Chat allowFrom (users/<id> or raw email; avoid users/<email>)",
|
||||
placeholder: "users/123456789, name@example.com",
|
||||
parseEntries: (raw) => ({
|
||||
entries: mergeAllowFromEntries(undefined, splitSetupEntries(raw)),
|
||||
}),
|
||||
getExistingAllowFrom: ({ cfg, accountId }) =>
|
||||
resolveGoogleChatAccount({ cfg, accountId }).config.dm?.allowFrom ?? [],
|
||||
applyAllowFrom: ({ cfg, accountId, allowFrom }) =>
|
||||
applySetupAccountConfigPatch({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: {
|
||||
dm: {
|
||||
...resolveGoogleChatAccount({ cfg, accountId }).config.dm,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const googlechatDmPolicy: ChannelSetupDmPolicy = {
|
||||
label: "Google Chat",
|
||||
channel,
|
||||
policyKey: "channels.googlechat.dm.policy",
|
||||
allowFromKey: "channels.googlechat.dm.allowFrom",
|
||||
resolveConfigKeys: (cfg, accountId) =>
|
||||
(accountId ?? resolveDefaultGoogleChatAccountId(cfg)) !== DEFAULT_ACCOUNT_ID
|
||||
? {
|
||||
policyKey: `channels.googlechat.accounts.${accountId ?? resolveDefaultGoogleChatAccountId(cfg)}.dm.policy`,
|
||||
allowFromKey: `channels.googlechat.accounts.${accountId ?? resolveDefaultGoogleChatAccountId(cfg)}.dm.allowFrom`,
|
||||
}
|
||||
: {
|
||||
policyKey: "channels.googlechat.dm.policy",
|
||||
allowFromKey: "channels.googlechat.dm.allowFrom",
|
||||
},
|
||||
getCurrent: (cfg, accountId) =>
|
||||
resolveGoogleChatAccount({
|
||||
cfg,
|
||||
accountId: accountId ?? resolveDefaultGoogleChatAccountId(cfg),
|
||||
}).config.dm?.policy ?? "pairing",
|
||||
setPolicy: (cfg, policy, accountId) => {
|
||||
const resolvedAccountId = accountId ?? resolveDefaultGoogleChatAccountId(cfg);
|
||||
const currentDm = resolveGoogleChatAccount({
|
||||
cfg,
|
||||
accountId: resolvedAccountId,
|
||||
}).config.dm;
|
||||
return applySetupAccountConfigPatch({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId: resolvedAccountId,
|
||||
patch: {
|
||||
dm: {
|
||||
...currentDm,
|
||||
policy,
|
||||
...(policy === "open" ? { allowFrom: addWildcardAllowFrom(currentDm?.allowFrom) } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
promptAllowFrom,
|
||||
};
|
||||
|
||||
export { googlechatSetupAdapter } from "./setup-core.js";
|
||||
|
||||
export const googlechatSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
status: createStandardChannelSetupStatus({
|
||||
channelLabel: "Google Chat",
|
||||
configuredLabel: "configured",
|
||||
unconfiguredLabel: "needs service account",
|
||||
configuredHint: "configured",
|
||||
unconfiguredHint: "needs auth",
|
||||
includeStatusLine: true,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none",
|
||||
}),
|
||||
introNote: {
|
||||
title: "Google Chat setup",
|
||||
lines: [
|
||||
"Google Chat apps use service-account auth and an HTTPS webhook.",
|
||||
"Set the Chat API scopes in your service account and configure the Chat app URL.",
|
||||
"Webhook verification requires audience type + audience value.",
|
||||
`Docs: ${formatDocsLink("/channels/googlechat", "googlechat")}`,
|
||||
],
|
||||
},
|
||||
prepare: async ({ cfg, accountId, credentialValues, prompter }) => {
|
||||
const envReady =
|
||||
accountId === DEFAULT_ACCOUNT_ID &&
|
||||
(Boolean(process.env[ENV_SERVICE_ACCOUNT]) || Boolean(process.env[ENV_SERVICE_ACCOUNT_FILE]));
|
||||
if (envReady) {
|
||||
const useEnv = await prompter.confirm({
|
||||
message: "Use GOOGLE_CHAT_SERVICE_ACCOUNT env vars?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (useEnv) {
|
||||
return {
|
||||
cfg: applySetupAccountConfigPatch({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: {},
|
||||
}),
|
||||
credentialValues: {
|
||||
...credentialValues,
|
||||
[USE_ENV_FLAG]: "1",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const method = await prompter.select({
|
||||
message: "Google Chat auth method",
|
||||
options: [
|
||||
{ value: "file", label: "Service account JSON file" },
|
||||
{ value: "inline", label: "Paste service account JSON" },
|
||||
],
|
||||
initialValue: "file",
|
||||
});
|
||||
|
||||
return {
|
||||
credentialValues: {
|
||||
...credentialValues,
|
||||
[USE_ENV_FLAG]: "0",
|
||||
[AUTH_METHOD_FLAG]: method,
|
||||
},
|
||||
};
|
||||
},
|
||||
credentials: [],
|
||||
textInputs: [
|
||||
{
|
||||
inputKey: "tokenFile",
|
||||
message: "Service account JSON path",
|
||||
placeholder: "/path/to/service-account.json",
|
||||
shouldPrompt: ({ credentialValues }) =>
|
||||
credentialValues[USE_ENV_FLAG] !== "1" && credentialValues[AUTH_METHOD_FLAG] === "file",
|
||||
validate: ({ value }) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
|
||||
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
|
||||
applySet: async ({ cfg, accountId, value }) =>
|
||||
applySetupAccountConfigPatch({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: { serviceAccountFile: value },
|
||||
}),
|
||||
},
|
||||
{
|
||||
inputKey: "token",
|
||||
message: "Service account JSON (single line)",
|
||||
placeholder: '{"type":"service_account", ... }',
|
||||
shouldPrompt: ({ credentialValues }) =>
|
||||
credentialValues[USE_ENV_FLAG] !== "1" && credentialValues[AUTH_METHOD_FLAG] === "inline",
|
||||
validate: ({ value }) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
|
||||
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
|
||||
applySet: async ({ cfg, accountId, value }) =>
|
||||
applySetupAccountConfigPatch({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: { serviceAccount: value },
|
||||
}),
|
||||
},
|
||||
],
|
||||
finalize: async ({ cfg, accountId, prompter }) => {
|
||||
const account = resolveGoogleChatAccount({
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
const audienceType = await prompter.select({
|
||||
message: "Webhook audience type",
|
||||
options: [
|
||||
{ value: "app-url", label: "App URL (recommended)" },
|
||||
{ value: "project-number", label: "Project number" },
|
||||
],
|
||||
initialValue: account.config.audienceType === "project-number" ? "project-number" : "app-url",
|
||||
});
|
||||
const audience = await prompter.text({
|
||||
message: audienceType === "project-number" ? "Project number" : "App URL",
|
||||
placeholder:
|
||||
audienceType === "project-number" ? "1234567890" : "https://your.host/googlechat",
|
||||
initialValue: account.config.audience || undefined,
|
||||
validate: (value) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
|
||||
});
|
||||
return {
|
||||
cfg: migrateBaseNameToDefaultAccount({
|
||||
cfg: applySetupAccountConfigPatch({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: {
|
||||
audienceType,
|
||||
audience: normalizeOptionalString(audience) ?? "",
|
||||
},
|
||||
}),
|
||||
channelKey: channel,
|
||||
}),
|
||||
};
|
||||
},
|
||||
dmPolicy: googlechatDmPolicy,
|
||||
};
|
||||
560
openclaw/extensions/googlechat/src/setup.test.ts
Normal file
560
openclaw/extensions/googlechat/src/setup.test.ts
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createPluginSetupWizardConfigure,
|
||||
createPluginSetupWizardStatus,
|
||||
createTestWizardPrompter,
|
||||
runSetupWizardConfigure,
|
||||
type WizardPrompter,
|
||||
} from "../../../test/helpers/plugins/setup-wizard.js";
|
||||
import {
|
||||
expectLifecyclePatch,
|
||||
expectPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
} from "../../../test/helpers/plugins/start-account-lifecycle.js";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
listGoogleChatAccountIds,
|
||||
resolveGoogleChatAccount,
|
||||
resolveDefaultGoogleChatAccountId,
|
||||
type ResolvedGoogleChatAccount,
|
||||
} from "./accounts.js";
|
||||
import { startGoogleChatGatewayAccount } from "./gateway.js";
|
||||
import { googlechatSetupAdapter } from "./setup-core.js";
|
||||
import { googlechatSetupWizard } from "./setup-surface.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
startGoogleChatMonitor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./channel.runtime.js", () => ({
|
||||
googleChatChannelRuntime: {
|
||||
resolveGoogleChatWebhookPath: ({
|
||||
account,
|
||||
}: {
|
||||
account: { config: { webhookPath?: string } };
|
||||
}) => account.config.webhookPath ?? "/googlechat",
|
||||
startGoogleChatMonitor: hoisted.startGoogleChatMonitor,
|
||||
},
|
||||
}));
|
||||
|
||||
const googlechatSetupPlugin = {
|
||||
id: "googlechat",
|
||||
meta: {
|
||||
label: "Google Chat",
|
||||
},
|
||||
config: {
|
||||
defaultAccountId: resolveDefaultGoogleChatAccountId,
|
||||
listAccountIds: listGoogleChatAccountIds,
|
||||
},
|
||||
setupWizard: googlechatSetupWizard,
|
||||
} as never;
|
||||
|
||||
const googlechatConfigure = createPluginSetupWizardConfigure(googlechatSetupPlugin);
|
||||
const googlechatStatus = createPluginSetupWizardStatus(googlechatSetupPlugin);
|
||||
|
||||
function buildAccount(): ResolvedGoogleChatAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "inline",
|
||||
credentials: {},
|
||||
config: {
|
||||
webhookPath: "/googlechat",
|
||||
webhookUrl: "https://example.com/googlechat",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForGoogleChatMonitorStarted() {
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
if (hoisted.startGoogleChatMonitor.mock.calls.length === 1) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
describe("googlechat setup", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("rejects env auth for non-default accounts", () => {
|
||||
if (!googlechatSetupAdapter.validateInput) {
|
||||
throw new Error("Expected googlechatSetupAdapter.validateInput to be defined");
|
||||
}
|
||||
expect(
|
||||
googlechatSetupAdapter.validateInput({
|
||||
accountId: "secondary",
|
||||
input: { useEnv: true },
|
||||
} as never),
|
||||
).toBe("GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account.");
|
||||
});
|
||||
|
||||
it("requires inline or file credentials when env auth is not used", () => {
|
||||
if (!googlechatSetupAdapter.validateInput) {
|
||||
throw new Error("Expected googlechatSetupAdapter.validateInput to be defined");
|
||||
}
|
||||
expect(
|
||||
googlechatSetupAdapter.validateInput({
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input: { useEnv: false, token: "", tokenFile: "" },
|
||||
} as never),
|
||||
).toBe("Google Chat requires --token (service account JSON) or --token-file.");
|
||||
});
|
||||
|
||||
it("builds a patch from token-file and trims optional webhook fields", () => {
|
||||
if (!googlechatSetupAdapter.applyAccountConfig) {
|
||||
throw new Error("Expected googlechatSetupAdapter.applyAccountConfig to be defined");
|
||||
}
|
||||
expect(
|
||||
googlechatSetupAdapter.applyAccountConfig({
|
||||
cfg: { channels: { googlechat: {} } },
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input: {
|
||||
name: "Default",
|
||||
tokenFile: "/tmp/googlechat.json",
|
||||
audienceType: " app-url ",
|
||||
audience: " https://example.com/googlechat ",
|
||||
webhookPath: " /googlechat ",
|
||||
webhookUrl: " https://example.com/googlechat/hook ",
|
||||
},
|
||||
} as never),
|
||||
).toEqual({
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
name: "Default",
|
||||
serviceAccountFile: "/tmp/googlechat.json",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
webhookPath: "/googlechat",
|
||||
webhookUrl: "https://example.com/googlechat/hook",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers inline token patch when token-file is absent", () => {
|
||||
if (!googlechatSetupAdapter.applyAccountConfig) {
|
||||
throw new Error("Expected googlechatSetupAdapter.applyAccountConfig to be defined");
|
||||
}
|
||||
expect(
|
||||
googlechatSetupAdapter.applyAccountConfig({
|
||||
cfg: { channels: { googlechat: {} } },
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input: {
|
||||
name: "Default",
|
||||
token: { client_email: "bot@example.com" },
|
||||
},
|
||||
} as never),
|
||||
).toEqual({
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
name: "Default",
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("configures service-account auth and webhook audience", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Service account JSON path") {
|
||||
return "/tmp/googlechat-service-account.json";
|
||||
}
|
||||
if (message === "App URL") {
|
||||
return "https://example.com/googlechat";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
});
|
||||
|
||||
const result = await runSetupWizardConfigure({
|
||||
configure: googlechatConfigure,
|
||||
cfg: {} as OpenClawConfig,
|
||||
prompter,
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(result.accountId).toBe("default");
|
||||
expect(result.cfg.channels?.googlechat?.enabled).toBe(true);
|
||||
expect(result.cfg.channels?.googlechat?.serviceAccountFile).toBe(
|
||||
"/tmp/googlechat-service-account.json",
|
||||
);
|
||||
expect(result.cfg.channels?.googlechat?.audienceType).toBe("app-url");
|
||||
expect(result.cfg.channels?.googlechat?.audience).toBe("https://example.com/googlechat");
|
||||
});
|
||||
|
||||
it("reads the named-account DM policy instead of the channel root", () => {
|
||||
expect(
|
||||
googlechatSetupWizard.dmPolicy?.getCurrent(
|
||||
{
|
||||
channels: {
|
||||
googlechat: {
|
||||
dm: {
|
||||
policy: "disabled",
|
||||
},
|
||||
accounts: {
|
||||
alerts: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
"alerts",
|
||||
),
|
||||
).toBe("allowlist");
|
||||
});
|
||||
|
||||
it("reports configured state for the selected account instead of any account", async () => {
|
||||
const status = await googlechatStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
serviceAccount: { client_email: "default@example.com" },
|
||||
},
|
||||
alerts: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountOverrides: {
|
||||
googlechat: "alerts",
|
||||
},
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("reports configured state for the configured defaultAccount instead of any account", async () => {
|
||||
const status = await googlechatStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
googlechat: {
|
||||
defaultAccount: "alerts",
|
||||
accounts: {
|
||||
default: {
|
||||
serviceAccount: { client_email: "default@example.com" },
|
||||
},
|
||||
alerts: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountOverrides: {},
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("reports account-scoped config keys for named accounts", () => {
|
||||
expect(googlechatSetupWizard.dmPolicy?.resolveConfigKeys?.({}, "alerts")).toEqual({
|
||||
policyKey: "channels.googlechat.accounts.alerts.dm.policy",
|
||||
allowFromKey: "channels.googlechat.accounts.alerts.dm.allowFrom",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted DM policy account context", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
defaultAccount: "alerts",
|
||||
dm: {
|
||||
policy: "disabled",
|
||||
},
|
||||
accounts: {
|
||||
alerts: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(googlechatSetupWizard.dmPolicy?.getCurrent(cfg)).toBe("allowlist");
|
||||
expect(googlechatSetupWizard.dmPolicy?.resolveConfigKeys?.(cfg)).toEqual({
|
||||
policyKey: "channels.googlechat.accounts.alerts.dm.policy",
|
||||
allowFromKey: "channels.googlechat.accounts.alerts.dm.allowFrom",
|
||||
});
|
||||
|
||||
const next = googlechatSetupWizard.dmPolicy?.setPolicy(cfg, "open");
|
||||
expect(next?.channels?.googlechat?.dm?.policy).toBe("disabled");
|
||||
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.policy).toBe("open");
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted allowFrom prompt context", async () => {
|
||||
const prompter = {
|
||||
note: vi.fn(async () => {}),
|
||||
text: vi.fn(async () => "users/123456789"),
|
||||
};
|
||||
|
||||
const next = await googlechatSetupWizard.dmPolicy?.promptAllowFrom?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
googlechat: {
|
||||
defaultAccount: "alerts",
|
||||
dm: {
|
||||
allowFrom: ["users/root"],
|
||||
},
|
||||
accounts: {
|
||||
alerts: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: {
|
||||
allowFrom: ["users/alerts"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
prompter: prompter as any,
|
||||
});
|
||||
|
||||
expect(next?.channels?.googlechat?.dm?.allowFrom).toEqual(["users/root"]);
|
||||
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.allowFrom).toEqual([
|
||||
"users/123456789",
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes open DM policy to the named account and preserves inherited allowFrom with "*"', () => {
|
||||
const next = googlechatSetupWizard.dmPolicy?.setPolicy(
|
||||
{
|
||||
channels: {
|
||||
googlechat: {
|
||||
dm: {
|
||||
allowFrom: ["users/123"],
|
||||
},
|
||||
accounts: {
|
||||
alerts: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
"open",
|
||||
"alerts",
|
||||
);
|
||||
|
||||
expect(next?.channels?.googlechat?.dm?.policy).toBeUndefined();
|
||||
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.policy).toBe("open");
|
||||
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.allowFrom).toEqual(["users/123", "*"]);
|
||||
});
|
||||
|
||||
it("keeps startAccount pending until abort, then unregisters", async () => {
|
||||
const unregister = vi.fn();
|
||||
hoisted.startGoogleChatMonitor.mockResolvedValue(unregister);
|
||||
|
||||
const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({
|
||||
startAccount: startGoogleChatGatewayAccount,
|
||||
account: buildAccount(),
|
||||
});
|
||||
await expectPendingUntilAbort({
|
||||
waitForStarted: waitForGoogleChatMonitorStarted,
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
assertBeforeAbort: () => {
|
||||
expect(unregister).not.toHaveBeenCalled();
|
||||
},
|
||||
assertAfterAbort: () => {
|
||||
expect(unregister).toHaveBeenCalledOnce();
|
||||
},
|
||||
});
|
||||
expectLifecyclePatch(patches, { running: true });
|
||||
expectLifecyclePatch(patches, { running: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGoogleChatAccount", () => {
|
||||
it("parses default-account env JSON credentials only when they decode to an object", () => {
|
||||
vi.stubEnv("GOOGLE_CHAT_SERVICE_ACCOUNT", '{"client_email":"bot@example.com"}');
|
||||
|
||||
const resolved = resolveGoogleChatAccount({
|
||||
cfg: { channels: { googlechat: {} } },
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolved.credentialSource).toBe("env");
|
||||
expect(resolved.credentials).toEqual({ client_email: "bot@example.com" });
|
||||
});
|
||||
|
||||
it("ignores env JSON credentials when they decode to a non-object value", () => {
|
||||
vi.stubEnv("GOOGLE_CHAT_SERVICE_ACCOUNT", '["not","an","object"]');
|
||||
vi.stubEnv("GOOGLE_CHAT_SERVICE_ACCOUNT_FILE", "/tmp/googlechat.json");
|
||||
|
||||
const resolved = resolveGoogleChatAccount({
|
||||
cfg: { channels: { googlechat: {} } },
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolved.credentialSource).toBe("env");
|
||||
expect(resolved.credentials).toBeUndefined();
|
||||
expect(resolved.credentialsFile).toBe("/tmp/googlechat.json");
|
||||
});
|
||||
|
||||
it("inherits shared defaults from accounts.default for named accounts", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
webhookPath: "/googlechat",
|
||||
},
|
||||
andy: {
|
||||
serviceAccountFile: "/tmp/andy-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" });
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
expect(resolved.config.audience).toBe("https://example.com/googlechat");
|
||||
expect(resolved.config.webhookPath).toBe("/googlechat");
|
||||
expect(resolved.config.serviceAccountFile).toBe("/tmp/andy-sa.json");
|
||||
});
|
||||
|
||||
it("prefers top-level and account overrides over accounts.default", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
audienceType: "project-number",
|
||||
audience: "1234567890",
|
||||
accounts: {
|
||||
default: {
|
||||
audienceType: "app-url",
|
||||
audience: "https://default.example.com/googlechat",
|
||||
webhookPath: "/googlechat-default",
|
||||
},
|
||||
april: {
|
||||
webhookPath: "/googlechat-april",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "april" });
|
||||
expect(resolved.config.audienceType).toBe("project-number");
|
||||
expect(resolved.config.audience).toBe("1234567890");
|
||||
expect(resolved.config.webhookPath).toBe("/googlechat-april");
|
||||
});
|
||||
|
||||
it("does not inherit disabled state from accounts.default for named accounts", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
enabled: false,
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
andy: {
|
||||
serviceAccountFile: "/tmp/andy-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" });
|
||||
expect(resolved.enabled).toBe(true);
|
||||
expect(resolved.config.enabled).toBeUndefined();
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
});
|
||||
|
||||
it("does not inherit default-account credentials into named accounts", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
serviceAccountRef: {
|
||||
source: "env",
|
||||
provider: "test",
|
||||
id: "default-sa",
|
||||
},
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
andy: {
|
||||
serviceAccountFile: "/tmp/andy-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" });
|
||||
expect(resolved.credentialSource).toBe("file");
|
||||
expect(resolved.credentialsFile).toBe("/tmp/andy-sa.json");
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
});
|
||||
|
||||
it("does not inherit dangerous name matching from accounts.default", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
dangerouslyAllowNameMatching: true,
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
andy: {
|
||||
serviceAccountFile: "/tmp/andy-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" });
|
||||
expect(resolved.config.dangerouslyAllowNameMatching).toBeUndefined();
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
defaultAccount: "alerts",
|
||||
accounts: {
|
||||
alerts: {
|
||||
serviceAccountFile: "/tmp/alerts-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg });
|
||||
expect(resolved.accountId).toBe("alerts");
|
||||
expect(resolved.credentialSource).toBe("file");
|
||||
expect(resolved.credentialsFile).toBe("/tmp/alerts-sa.json");
|
||||
});
|
||||
});
|
||||
328
openclaw/extensions/googlechat/src/targets.test.ts
Normal file
328
openclaw/extensions/googlechat/src/targets.test.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js";
|
||||
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
|
||||
import { isSenderAllowed } from "./sender-allow.js";
|
||||
import {
|
||||
isGoogleChatSpaceTarget,
|
||||
isGoogleChatUserTarget,
|
||||
normalizeGoogleChatTarget,
|
||||
} from "./targets.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuard: vi.fn(async (params: { url: string; init?: RequestInit }) => ({
|
||||
response: await fetch(params.url, params.init),
|
||||
release: async () => {},
|
||||
})),
|
||||
verifySignedJwtWithCertsAsync: vi.fn(),
|
||||
verifyIdToken: vi.fn(),
|
||||
getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => {
|
||||
return {
|
||||
fetchWithSsrFGuard: mocks.fetchWithSsrFGuard,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("google-auth-library", () => ({
|
||||
GoogleAuth: function GoogleAuth() {},
|
||||
OAuth2Client: class {
|
||||
verifyIdToken = mocks.verifyIdToken;
|
||||
verifySignedJwtWithCertsAsync = mocks.verifySignedJwtWithCertsAsync;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./auth.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./auth.js")>("./auth.js");
|
||||
return {
|
||||
...actual,
|
||||
getGoogleChatAccessToken: mocks.getGoogleChatAccessToken,
|
||||
};
|
||||
});
|
||||
|
||||
const { verifyGoogleChatRequest } = await import("./auth.js");
|
||||
|
||||
const account = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "inline",
|
||||
config: {},
|
||||
} as ResolvedGoogleChatAccount;
|
||||
|
||||
function stubSuccessfulSend(name: string) {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ name }), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
async function expectDownloadToRejectForResponse(response: Response) {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
||||
await expect(
|
||||
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
|
||||
).rejects.toThrow(/max bytes/i);
|
||||
}
|
||||
|
||||
describe("normalizeGoogleChatTarget", () => {
|
||||
it("normalizes provider prefixes", () => {
|
||||
expect(normalizeGoogleChatTarget("googlechat:users/123")).toBe("users/123");
|
||||
expect(normalizeGoogleChatTarget("google-chat:spaces/AAA")).toBe("spaces/AAA");
|
||||
expect(normalizeGoogleChatTarget("gchat:user:User@Example.com")).toBe("users/user@example.com");
|
||||
});
|
||||
|
||||
it("normalizes email targets to users/<email>", () => {
|
||||
expect(normalizeGoogleChatTarget("User@Example.com")).toBe("users/user@example.com");
|
||||
expect(normalizeGoogleChatTarget("users/User@Example.com")).toBe("users/user@example.com");
|
||||
});
|
||||
|
||||
it("preserves space targets", () => {
|
||||
expect(normalizeGoogleChatTarget("space:spaces/BBB")).toBe("spaces/BBB");
|
||||
expect(normalizeGoogleChatTarget("spaces/CCC")).toBe("spaces/CCC");
|
||||
});
|
||||
});
|
||||
|
||||
describe("target helpers", () => {
|
||||
it("detects user and space targets", () => {
|
||||
expect(isGoogleChatUserTarget("users/abc")).toBe(true);
|
||||
expect(isGoogleChatSpaceTarget("spaces/abc")).toBe(true);
|
||||
expect(isGoogleChatUserTarget("spaces/abc")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechat group policy", () => {
|
||||
it("uses generic channel group policy helpers", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
groups: {
|
||||
"spaces/AAA": {
|
||||
requireMention: false,
|
||||
},
|
||||
"*": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/AAA" })).toBe(false);
|
||||
expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/BBB" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSenderAllowed", () => {
|
||||
it("matches raw email entries only when dangerous name matching is enabled", () => {
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(false);
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat users/<email> entries as email allowlist (deprecated form)", () => {
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["users/jane@example.com"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("still matches user id entries", () => {
|
||||
expect(isSenderAllowed("users/abc", "jane@example.com", ["users/abc"])).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-matching raw email entries", () => {
|
||||
expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"], true)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadGoogleChatMedia", () => {
|
||||
afterEach(() => {
|
||||
mocks.fetchWithSsrFGuard.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("rejects when content-length exceeds max bytes", async () => {
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const response = new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-length": "50", "content-type": "application/octet-stream" },
|
||||
});
|
||||
await expectDownloadToRejectForResponse(response);
|
||||
});
|
||||
|
||||
it("rejects when streamed payload exceeds max bytes", async () => {
|
||||
const chunks = [new Uint8Array(6), new Uint8Array(6)];
|
||||
let index = 0;
|
||||
const body = new ReadableStream({
|
||||
pull(controller) {
|
||||
if (index < chunks.length) {
|
||||
controller.enqueue(chunks[index++]);
|
||||
} else {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
const response = new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
});
|
||||
await expectDownloadToRejectForResponse(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendGoogleChatMessage", () => {
|
||||
afterEach(() => {
|
||||
mocks.fetchWithSsrFGuard.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("adds messageReplyOption when sending to an existing thread", async () => {
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/123");
|
||||
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
thread: "spaces/AAA/threads/xyz",
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(String(url)).toContain("messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD");
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
text: "hello",
|
||||
thread: { name: "spaces/AAA/threads/xyz" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set messageReplyOption for non-thread sends", async () => {
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/124");
|
||||
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(String(url)).not.toContain("messageReplyOption=");
|
||||
});
|
||||
});
|
||||
|
||||
function mockTicket(payload: Record<string, unknown>) {
|
||||
mocks.verifyIdToken.mockResolvedValue({
|
||||
getPayload: () => payload,
|
||||
});
|
||||
}
|
||||
|
||||
describe("verifyGoogleChatRequest", () => {
|
||||
it("accepts Google Chat app-url tokens from the Chat issuer", async () => {
|
||||
mocks.verifyIdToken.mockReset();
|
||||
mockTicket({
|
||||
email: "chat@system.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects add-on tokens when no principal binding is configured", async () => {
|
||||
mocks.verifyIdToken.mockReset();
|
||||
mockTicket({
|
||||
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
sub: "principal-1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "missing add-on principal binding",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts add-on tokens only when the bound principal matches", async () => {
|
||||
mocks.verifyIdToken.mockReset();
|
||||
mockTicket({
|
||||
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
sub: "principal-1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
expectedAddOnPrincipal: "principal-1",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects add-on tokens when the bound principal does not match", async () => {
|
||||
mocks.verifyIdToken.mockReset();
|
||||
mockTicket({
|
||||
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
sub: "principal-2",
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
expectedAddOnPrincipal: "principal-1",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "unexpected add-on principal: principal-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Chat certs through the guarded fetch for project-number tokens", async () => {
|
||||
const release = vi.fn();
|
||||
mocks.fetchWithSsrFGuard.mockClear();
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: new Response(JSON.stringify({ "kid-1": "cert-body" }), { status: 200 }),
|
||||
release,
|
||||
});
|
||||
mocks.verifySignedJwtWithCertsAsync.mockReset().mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "project-number",
|
||||
audience: "123456789",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(mocks.fetchWithSsrFGuard).toHaveBeenCalledWith({
|
||||
url: "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com",
|
||||
auditContext: "googlechat.auth.certs",
|
||||
});
|
||||
expect(mocks.verifySignedJwtWithCertsAsync).toHaveBeenCalledWith(
|
||||
"token",
|
||||
{ "kid-1": "cert-body" },
|
||||
"123456789",
|
||||
["chat@system.gserviceaccount.com"],
|
||||
);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
69
openclaw/extensions/googlechat/src/targets.ts
Normal file
69
openclaw/extensions/googlechat/src/targets.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { findGoogleChatDirectMessage } from "./api.js";
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function normalizeGoogleChatTarget(raw?: string | null): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, "");
|
||||
const normalized = withoutPrefix
|
||||
.replace(/^user:(users\/)?/i, "users/")
|
||||
.replace(/^space:(spaces\/)?/i, "spaces/");
|
||||
if (isGoogleChatUserTarget(normalized)) {
|
||||
const suffix = normalized.slice("users/".length);
|
||||
return suffix.includes("@") ? `users/${normalizeLowercaseStringOrEmpty(suffix)}` : normalized;
|
||||
}
|
||||
if (isGoogleChatSpaceTarget(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
if (normalized.includes("@")) {
|
||||
return `users/${normalizeLowercaseStringOrEmpty(normalized)}`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function isGoogleChatUserTarget(value: string): boolean {
|
||||
return normalizeLowercaseStringOrEmpty(value).startsWith("users/");
|
||||
}
|
||||
|
||||
export function isGoogleChatSpaceTarget(value: string): boolean {
|
||||
return normalizeLowercaseStringOrEmpty(value).startsWith("spaces/");
|
||||
}
|
||||
|
||||
function stripMessageSuffix(target: string): string {
|
||||
const index = target.indexOf("/messages/");
|
||||
if (index === -1) {
|
||||
return target;
|
||||
}
|
||||
return target.slice(0, index);
|
||||
}
|
||||
|
||||
export async function resolveGoogleChatOutboundSpace(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
target: string;
|
||||
}): Promise<string> {
|
||||
const normalized = normalizeGoogleChatTarget(params.target);
|
||||
if (!normalized) {
|
||||
throw new Error("Missing Google Chat target.");
|
||||
}
|
||||
const base = stripMessageSuffix(normalized);
|
||||
if (isGoogleChatSpaceTarget(base)) {
|
||||
return base;
|
||||
}
|
||||
if (isGoogleChatUserTarget(base)) {
|
||||
const dm = await findGoogleChatDirectMessage({
|
||||
account: params.account,
|
||||
userName: base,
|
||||
});
|
||||
if (!dm?.name) {
|
||||
throw new Error(`No Google Chat DM found for ${base}`);
|
||||
}
|
||||
return dm.name;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
3
openclaw/extensions/googlechat/src/types.config.ts
Normal file
3
openclaw/extensions/googlechat/src/types.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import type { GoogleChatAccountConfig, GoogleChatConfig } from "../runtime-api.js";
|
||||
|
||||
export type { GoogleChatAccountConfig, GoogleChatConfig };
|
||||
73
openclaw/extensions/googlechat/src/types.ts
Normal file
73
openclaw/extensions/googlechat/src/types.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
export type GoogleChatSpace = {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type GoogleChatUser = {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type GoogleChatThread = {
|
||||
name?: string;
|
||||
threadKey?: string;
|
||||
};
|
||||
|
||||
export type GoogleChatAttachmentDataRef = {
|
||||
resourceName?: string;
|
||||
attachmentUploadToken?: string;
|
||||
};
|
||||
|
||||
export type GoogleChatAttachment = {
|
||||
name?: string;
|
||||
contentName?: string;
|
||||
contentType?: string;
|
||||
thumbnailUri?: string;
|
||||
downloadUri?: string;
|
||||
source?: string;
|
||||
attachmentDataRef?: GoogleChatAttachmentDataRef;
|
||||
driveDataRef?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type GoogleChatUserMention = {
|
||||
user?: GoogleChatUser;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type GoogleChatAnnotation = {
|
||||
type?: string;
|
||||
startIndex?: number;
|
||||
length?: number;
|
||||
userMention?: GoogleChatUserMention;
|
||||
slashCommand?: Record<string, unknown>;
|
||||
richLinkMetadata?: Record<string, unknown>;
|
||||
customEmojiMetadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type GoogleChatMessage = {
|
||||
name?: string;
|
||||
text?: string;
|
||||
argumentText?: string;
|
||||
sender?: GoogleChatUser;
|
||||
thread?: GoogleChatThread;
|
||||
attachment?: GoogleChatAttachment[];
|
||||
annotations?: GoogleChatAnnotation[];
|
||||
};
|
||||
|
||||
export type GoogleChatEvent = {
|
||||
type?: string;
|
||||
eventType?: string;
|
||||
eventTime?: string;
|
||||
space?: GoogleChatSpace;
|
||||
user?: GoogleChatUser;
|
||||
message?: GoogleChatMessage;
|
||||
};
|
||||
|
||||
export type GoogleChatReaction = {
|
||||
name?: string;
|
||||
user?: GoogleChatUser;
|
||||
emoji?: { unicode?: string };
|
||||
};
|
||||
2
openclaw/extensions/googlechat/test-api.ts
Normal file
2
openclaw/extensions/googlechat/test-api.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { googlechatPlugin } from "./src/channel.js";
|
||||
export { setGoogleChatRuntime } from "./src/runtime.js";
|
||||
16
openclaw/extensions/googlechat/tsconfig.json
Normal file
16
openclaw/extensions/googlechat/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue