Spaces:
Running
Running
| import { | |
| applyAccountNameToChannelSection, | |
| buildChannelConfigSchema, | |
| DEFAULT_ACCOUNT_ID, | |
| deleteAccountFromConfigSection, | |
| formatPairingApproveHint, | |
| getChatChannelMeta, | |
| migrateBaseNameToDefaultAccount, | |
| missingTargetError, | |
| normalizeAccountId, | |
| PAIRING_APPROVED_MESSAGE, | |
| resolveChannelMediaMaxBytes, | |
| resolveGoogleChatGroupRequireMention, | |
| setAccountEnabledInConfigSection, | |
| type ChannelDock, | |
| type ChannelMessageActionAdapter, | |
| type ChannelPlugin, | |
| type OpenClawConfig, | |
| } from "openclaw/plugin-sdk"; | |
| import { GoogleChatConfigSchema } from "openclaw/plugin-sdk"; | |
| import { | |
| listGoogleChatAccountIds, | |
| resolveDefaultGoogleChatAccountId, | |
| resolveGoogleChatAccount, | |
| type ResolvedGoogleChatAccount, | |
| } from "./accounts.js"; | |
| import { googlechatMessageActions } from "./actions.js"; | |
| import { sendGoogleChatMessage, uploadGoogleChatAttachment, probeGoogleChat } from "./api.js"; | |
| import { resolveGoogleChatWebhookPath, startGoogleChatMonitor } from "./monitor.js"; | |
| import { googlechatOnboardingAdapter } from "./onboarding.js"; | |
| import { getGoogleChatRuntime } from "./runtime.js"; | |
| import { | |
| isGoogleChatSpaceTarget, | |
| isGoogleChatUserTarget, | |
| normalizeGoogleChatTarget, | |
| resolveGoogleChatOutboundSpace, | |
| } from "./targets.js"; | |
| const meta = getChatChannelMeta("googlechat"); | |
| const formatAllowFromEntry = (entry: string) => | |
| entry | |
| .trim() | |
| .replace(/^(googlechat|google-chat|gchat):/i, "") | |
| .replace(/^user:/i, "") | |
| .replace(/^users\//i, "") | |
| .toLowerCase(); | |
| export const googlechatDock: ChannelDock = { | |
| id: "googlechat", | |
| capabilities: { | |
| chatTypes: ["direct", "group", "thread"], | |
| reactions: true, | |
| media: true, | |
| threads: true, | |
| blockStreaming: true, | |
| }, | |
| outbound: { textChunkLimit: 4000 }, | |
| config: { | |
| resolveAllowFrom: ({ cfg, accountId }) => | |
| (resolveGoogleChatAccount({ cfg: cfg, accountId }).config.dm?.allowFrom ?? []).map((entry) => | |
| String(entry), | |
| ), | |
| formatAllowFrom: ({ allowFrom }) => | |
| allowFrom | |
| .map((entry) => String(entry)) | |
| .filter(Boolean) | |
| .map(formatAllowFromEntry), | |
| }, | |
| groups: { | |
| resolveRequireMention: resolveGoogleChatGroupRequireMention, | |
| }, | |
| threading: { | |
| resolveReplyToMode: ({ cfg }) => cfg.channels?.["googlechat"]?.replyToMode ?? "off", | |
| buildToolContext: ({ context, hasRepliedRef }) => { | |
| const threadId = context.MessageThreadId ?? context.ReplyToId; | |
| return { | |
| currentChannelId: context.To?.trim() || undefined, | |
| currentThreadTs: threadId != null ? String(threadId) : undefined, | |
| hasRepliedRef, | |
| }; | |
| }, | |
| }, | |
| }; | |
| const googlechatActions: ChannelMessageActionAdapter = { | |
| listActions: (ctx) => googlechatMessageActions.listActions?.(ctx) ?? [], | |
| 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: ChannelPlugin<ResolvedGoogleChatAccount> = { | |
| id: "googlechat", | |
| meta: { ...meta }, | |
| onboarding: googlechatOnboardingAdapter, | |
| pairing: { | |
| idLabel: "googlechatUserId", | |
| normalizeAllowEntry: (entry) => formatAllowFromEntry(entry), | |
| notifyApproval: async ({ cfg, id }) => { | |
| const account = resolveGoogleChatAccount({ cfg: cfg }); | |
| if (account.credentialSource === "none") { | |
| return; | |
| } | |
| const user = normalizeGoogleChatTarget(id) ?? id; | |
| const target = isGoogleChatUserTarget(user) ? user : `users/${user}`; | |
| const space = await resolveGoogleChatOutboundSpace({ account, target }); | |
| await sendGoogleChatMessage({ | |
| account, | |
| space, | |
| text: PAIRING_APPROVED_MESSAGE, | |
| }); | |
| }, | |
| }, | |
| 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: { | |
| listAccountIds: (cfg) => listGoogleChatAccountIds(cfg), | |
| resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg: cfg, accountId }), | |
| defaultAccountId: (cfg) => resolveDefaultGoogleChatAccountId(cfg), | |
| setAccountEnabled: ({ cfg, accountId, enabled }) => | |
| setAccountEnabledInConfigSection({ | |
| cfg: cfg, | |
| sectionKey: "googlechat", | |
| accountId, | |
| enabled, | |
| allowTopLevel: true, | |
| }), | |
| deleteAccount: ({ cfg, accountId }) => | |
| deleteAccountFromConfigSection({ | |
| cfg: cfg, | |
| sectionKey: "googlechat", | |
| accountId, | |
| clearBaseFields: [ | |
| "serviceAccount", | |
| "serviceAccountFile", | |
| "audienceType", | |
| "audience", | |
| "webhookPath", | |
| "webhookUrl", | |
| "botUser", | |
| "name", | |
| ], | |
| }), | |
| isConfigured: (account) => account.credentialSource !== "none", | |
| describeAccount: (account) => ({ | |
| accountId: account.accountId, | |
| name: account.name, | |
| enabled: account.enabled, | |
| configured: account.credentialSource !== "none", | |
| credentialSource: account.credentialSource, | |
| }), | |
| resolveAllowFrom: ({ cfg, accountId }) => | |
| ( | |
| resolveGoogleChatAccount({ | |
| cfg: cfg, | |
| accountId, | |
| }).config.dm?.allowFrom ?? [] | |
| ).map((entry) => String(entry)), | |
| formatAllowFrom: ({ allowFrom }) => | |
| allowFrom | |
| .map((entry) => String(entry)) | |
| .filter(Boolean) | |
| .map(formatAllowFromEntry), | |
| }, | |
| security: { | |
| resolveDmPolicy: ({ cfg, accountId, account }) => { | |
| const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; | |
| const useAccountPath = Boolean(cfg.channels?.["googlechat"]?.accounts?.[resolvedAccountId]); | |
| const allowFromPath = useAccountPath | |
| ? `channels.googlechat.accounts.${resolvedAccountId}.dm.` | |
| : "channels.googlechat.dm."; | |
| return { | |
| policy: account.config.dm?.policy ?? "pairing", | |
| allowFrom: account.config.dm?.allowFrom ?? [], | |
| allowFromPath, | |
| approveHint: formatPairingApproveHint("googlechat"), | |
| normalizeEntry: (raw) => formatAllowFromEntry(raw), | |
| }; | |
| }, | |
| collectWarnings: ({ account, cfg }) => { | |
| const warnings: string[] = []; | |
| const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; | |
| const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; | |
| if (groupPolicy === "open") { | |
| warnings.push( | |
| `- Google Chat spaces: groupPolicy="open" allows any space to trigger (mention-gated). Set channels.googlechat.groupPolicy="allowlist" and configure channels.googlechat.groups.`, | |
| ); | |
| } | |
| if (account.config.dm?.policy === "open") { | |
| warnings.push( | |
| `- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".`, | |
| ); | |
| } | |
| return warnings; | |
| }, | |
| }, | |
| groups: { | |
| resolveRequireMention: resolveGoogleChatGroupRequireMention, | |
| }, | |
| threading: { | |
| resolveReplyToMode: ({ cfg }) => cfg.channels?.["googlechat"]?.replyToMode ?? "off", | |
| }, | |
| messaging: { | |
| normalizeTarget: normalizeGoogleChatTarget, | |
| targetResolver: { | |
| looksLikeId: (raw, normalized) => { | |
| const value = normalized ?? raw.trim(); | |
| return isGoogleChatSpaceTarget(value) || isGoogleChatUserTarget(value); | |
| }, | |
| hint: "<spaces/{space}|users/{user}>", | |
| }, | |
| }, | |
| directory: { | |
| self: async () => null, | |
| listPeers: async ({ cfg, accountId, query, limit }) => { | |
| const account = resolveGoogleChatAccount({ | |
| cfg: cfg, | |
| accountId, | |
| }); | |
| const q = query?.trim().toLowerCase() || ""; | |
| const allowFrom = account.config.dm?.allowFrom ?? []; | |
| const peers = Array.from( | |
| new Set( | |
| allowFrom | |
| .map((entry) => String(entry).trim()) | |
| .filter((entry) => Boolean(entry) && entry !== "*") | |
| .map((entry) => normalizeGoogleChatTarget(entry) ?? entry), | |
| ), | |
| ) | |
| .filter((id) => (q ? id.toLowerCase().includes(q) : true)) | |
| .slice(0, limit && limit > 0 ? limit : undefined) | |
| .map((id) => ({ kind: "user", id }) as const); | |
| return peers; | |
| }, | |
| listGroups: async ({ cfg, accountId, query, limit }) => { | |
| const account = resolveGoogleChatAccount({ | |
| cfg: cfg, | |
| accountId, | |
| }); | |
| const groups = account.config.groups ?? {}; | |
| const q = query?.trim().toLowerCase() || ""; | |
| const entries = Object.keys(groups) | |
| .filter((key) => key && key !== "*") | |
| .filter((key) => (q ? key.toLowerCase().includes(q) : true)) | |
| .slice(0, limit && limit > 0 ? limit : undefined) | |
| .map((id) => ({ kind: "group", id }) as const); | |
| return entries; | |
| }, | |
| }, | |
| 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, | |
| setup: { | |
| resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), | |
| applyAccountName: ({ cfg, accountId, name }) => | |
| applyAccountNameToChannelSection({ | |
| cfg: cfg, | |
| channelKey: "googlechat", | |
| accountId, | |
| name, | |
| }), | |
| validateInput: ({ accountId, input }) => { | |
| if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { | |
| return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account."; | |
| } | |
| if (!input.useEnv && !input.token && !input.tokenFile) { | |
| return "Google Chat requires --token (service account JSON) or --token-file."; | |
| } | |
| return null; | |
| }, | |
| applyAccountConfig: ({ cfg, accountId, input }) => { | |
| const namedConfig = applyAccountNameToChannelSection({ | |
| cfg: cfg, | |
| channelKey: "googlechat", | |
| accountId, | |
| name: input.name, | |
| }); | |
| const next = | |
| accountId !== DEFAULT_ACCOUNT_ID | |
| ? migrateBaseNameToDefaultAccount({ | |
| cfg: namedConfig, | |
| channelKey: "googlechat", | |
| }) | |
| : namedConfig; | |
| 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(); | |
| const configPatch = { | |
| ...patch, | |
| ...(audienceType ? { audienceType } : {}), | |
| ...(audience ? { audience } : {}), | |
| ...(webhookPath ? { webhookPath } : {}), | |
| ...(webhookUrl ? { webhookUrl } : {}), | |
| }; | |
| if (accountId === DEFAULT_ACCOUNT_ID) { | |
| return { | |
| ...next, | |
| channels: { | |
| ...next.channels, | |
| googlechat: { | |
| ...next.channels?.["googlechat"], | |
| enabled: true, | |
| ...configPatch, | |
| }, | |
| }, | |
| } as OpenClawConfig; | |
| } | |
| return { | |
| ...next, | |
| channels: { | |
| ...next.channels, | |
| googlechat: { | |
| ...next.channels?.["googlechat"], | |
| enabled: true, | |
| accounts: { | |
| ...next.channels?.["googlechat"]?.accounts, | |
| [accountId]: { | |
| ...next.channels?.["googlechat"]?.accounts?.[accountId], | |
| enabled: true, | |
| ...configPatch, | |
| }, | |
| }, | |
| }, | |
| }, | |
| } as OpenClawConfig; | |
| }, | |
| }, | |
| outbound: { | |
| deliveryMode: "direct", | |
| chunker: (text, limit) => getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit), | |
| chunkerMode: "markdown", | |
| textChunkLimit: 4000, | |
| resolveTarget: ({ to, allowFrom, mode }) => { | |
| const trimmed = to?.trim() ?? ""; | |
| const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean); | |
| const allowList = allowListRaw | |
| .filter((entry) => entry !== "*") | |
| .map((entry) => normalizeGoogleChatTarget(entry)) | |
| .filter((entry): entry is string => Boolean(entry)); | |
| if (trimmed) { | |
| const normalized = normalizeGoogleChatTarget(trimmed); | |
| if (!normalized) { | |
| if ((mode === "implicit" || mode === "heartbeat") && allowList.length > 0) { | |
| return { ok: true, to: allowList[0] }; | |
| } | |
| return { | |
| ok: false, | |
| error: missingTargetError( | |
| "Google Chat", | |
| "<spaces/{space}|users/{user}> or channels.googlechat.dm.allowFrom[0]", | |
| ), | |
| }; | |
| } | |
| return { ok: true, to: normalized }; | |
| } | |
| if (allowList.length > 0) { | |
| return { ok: true, to: allowList[0] }; | |
| } | |
| return { | |
| ok: false, | |
| error: missingTargetError( | |
| "Google Chat", | |
| "<spaces/{space}|users/{user}> or channels.googlechat.dm.allowFrom[0]", | |
| ), | |
| }; | |
| }, | |
| sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { | |
| const account = resolveGoogleChatAccount({ | |
| cfg: cfg, | |
| accountId, | |
| }); | |
| const space = await resolveGoogleChatOutboundSpace({ account, target: to }); | |
| const thread = (threadId ?? replyToId ?? undefined) as string | undefined; | |
| const result = await sendGoogleChatMessage({ | |
| account, | |
| space, | |
| text, | |
| thread, | |
| }); | |
| return { | |
| channel: "googlechat", | |
| messageId: result?.messageName ?? "", | |
| chatId: space, | |
| }; | |
| }, | |
| sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => { | |
| 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 = (threadId ?? replyToId ?? undefined) as string | undefined; | |
| const runtime = getGoogleChatRuntime(); | |
| 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 loaded = await runtime.channel.media.fetchRemoteMedia(mediaUrl, { | |
| maxBytes: maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024, | |
| }); | |
| 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 { | |
| channel: "googlechat", | |
| messageId: result?.messageName ?? "", | |
| chatId: space, | |
| }; | |
| }, | |
| }, | |
| status: { | |
| defaultRuntime: { | |
| accountId: DEFAULT_ACCOUNT_ID, | |
| running: false, | |
| lastStartAt: null, | |
| lastStopAt: null, | |
| lastError: null, | |
| }, | |
| collectStatusIssues: (accounts) => | |
| accounts.flatMap((entry) => { | |
| const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID); | |
| const enabled = entry.enabled !== false; | |
| const configured = entry.configured === true; | |
| if (!enabled || !configured) { | |
| return []; | |
| } | |
| const issues = []; | |
| 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 }) => ({ | |
| configured: snapshot.configured ?? false, | |
| credentialSource: snapshot.credentialSource ?? "none", | |
| audienceType: snapshot.audienceType ?? null, | |
| audience: snapshot.audience ?? null, | |
| webhookPath: snapshot.webhookPath ?? null, | |
| webhookUrl: snapshot.webhookUrl ?? null, | |
| running: snapshot.running ?? false, | |
| lastStartAt: snapshot.lastStartAt ?? null, | |
| lastStopAt: snapshot.lastStopAt ?? null, | |
| lastError: snapshot.lastError ?? null, | |
| probe: snapshot.probe, | |
| lastProbeAt: snapshot.lastProbeAt ?? null, | |
| }), | |
| probeAccount: async ({ account }) => probeGoogleChat(account), | |
| buildAccountSnapshot: ({ account, runtime, probe }) => ({ | |
| accountId: account.accountId, | |
| name: account.name, | |
| enabled: account.enabled, | |
| configured: account.credentialSource !== "none", | |
| credentialSource: account.credentialSource, | |
| audienceType: account.config.audienceType, | |
| audience: account.config.audience, | |
| webhookPath: account.config.webhookPath, | |
| webhookUrl: account.config.webhookUrl, | |
| running: runtime?.running ?? false, | |
| lastStartAt: runtime?.lastStartAt ?? null, | |
| lastStopAt: runtime?.lastStopAt ?? null, | |
| lastError: runtime?.lastError ?? null, | |
| lastInboundAt: runtime?.lastInboundAt ?? null, | |
| lastOutboundAt: runtime?.lastOutboundAt ?? null, | |
| dmPolicy: account.config.dm?.policy ?? "pairing", | |
| probe, | |
| }), | |
| }, | |
| gateway: { | |
| startAccount: async (ctx) => { | |
| const account = ctx.account; | |
| ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`); | |
| ctx.setStatus({ | |
| accountId: account.accountId, | |
| running: true, | |
| lastStartAt: Date.now(), | |
| webhookPath: resolveGoogleChatWebhookPath({ account }), | |
| audienceType: account.config.audienceType, | |
| audience: account.config.audience, | |
| }); | |
| const unregister = await startGoogleChatMonitor({ | |
| account, | |
| config: ctx.cfg, | |
| runtime: ctx.runtime, | |
| abortSignal: ctx.abortSignal, | |
| webhookPath: account.config.webhookPath, | |
| webhookUrl: account.config.webhookUrl, | |
| statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }), | |
| }); | |
| return () => { | |
| unregister?.(); | |
| ctx.setStatus({ | |
| accountId: account.accountId, | |
| running: false, | |
| lastStopAt: Date.now(), | |
| }); | |
| }; | |
| }, | |
| }, | |
| }; | |