Spaces:
Paused
Paused
| import type { IncomingMessage, ServerResponse } from "node:http"; | |
| import type { OpenClawConfig } from "openclaw/plugin-sdk"; | |
| import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk"; | |
| import type { | |
| GoogleChatAnnotation, | |
| GoogleChatAttachment, | |
| GoogleChatEvent, | |
| GoogleChatSpace, | |
| GoogleChatMessage, | |
| GoogleChatUser, | |
| } from "./types.js"; | |
| import { type ResolvedGoogleChatAccount } from "./accounts.js"; | |
| import { | |
| downloadGoogleChatMedia, | |
| deleteGoogleChatMessage, | |
| sendGoogleChatMessage, | |
| updateGoogleChatMessage, | |
| } from "./api.js"; | |
| import { verifyGoogleChatRequest, type GoogleChatAudienceType } from "./auth.js"; | |
| import { 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; | |
| }; | |
| type GoogleChatCoreRuntime = ReturnType<typeof getGoogleChatRuntime>; | |
| 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; | |
| }; | |
| const webhookTargets = new Map<string, WebhookTarget[]>(); | |
| function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) { | |
| if (core.logging.shouldLogVerbose()) { | |
| runtime.log?.(`[googlechat] ${message}`); | |
| } | |
| } | |
| function normalizeWebhookPath(raw: string): string { | |
| const trimmed = raw.trim(); | |
| if (!trimmed) { | |
| return "/"; | |
| } | |
| const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; | |
| if (withSlash.length > 1 && withSlash.endsWith("/")) { | |
| return withSlash.slice(0, -1); | |
| } | |
| return withSlash; | |
| } | |
| function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null { | |
| const trimmedPath = webhookPath?.trim(); | |
| if (trimmedPath) { | |
| return normalizeWebhookPath(trimmedPath); | |
| } | |
| if (webhookUrl?.trim()) { | |
| try { | |
| const parsed = new URL(webhookUrl); | |
| return normalizeWebhookPath(parsed.pathname || "/"); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| return "/googlechat"; | |
| } | |
| async function readJsonBody(req: IncomingMessage, maxBytes: number) { | |
| const chunks: Buffer[] = []; | |
| let total = 0; | |
| return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => { | |
| let resolved = false; | |
| const doResolve = (value: { ok: boolean; value?: unknown; error?: string }) => { | |
| if (resolved) { | |
| return; | |
| } | |
| resolved = true; | |
| req.removeAllListeners(); | |
| resolve(value); | |
| }; | |
| req.on("data", (chunk: Buffer) => { | |
| total += chunk.length; | |
| if (total > maxBytes) { | |
| doResolve({ ok: false, error: "payload too large" }); | |
| req.destroy(); | |
| return; | |
| } | |
| chunks.push(chunk); | |
| }); | |
| req.on("end", () => { | |
| try { | |
| const raw = Buffer.concat(chunks).toString("utf8"); | |
| if (!raw.trim()) { | |
| doResolve({ ok: false, error: "empty payload" }); | |
| return; | |
| } | |
| doResolve({ ok: true, value: JSON.parse(raw) as unknown }); | |
| } catch (err) { | |
| doResolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); | |
| } | |
| }); | |
| req.on("error", (err) => { | |
| doResolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); | |
| }); | |
| }); | |
| } | |
| export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void { | |
| const key = normalizeWebhookPath(target.path); | |
| const normalizedTarget = { ...target, path: key }; | |
| const existing = webhookTargets.get(key) ?? []; | |
| const next = [...existing, normalizedTarget]; | |
| webhookTargets.set(key, next); | |
| return () => { | |
| const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget); | |
| if (updated.length > 0) { | |
| webhookTargets.set(key, updated); | |
| } else { | |
| webhookTargets.delete(key); | |
| } | |
| }; | |
| } | |
| function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined { | |
| const normalized = value?.trim().toLowerCase(); | |
| 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; | |
| } | |
| export async function handleGoogleChatWebhookRequest( | |
| req: IncomingMessage, | |
| res: ServerResponse, | |
| ): Promise<boolean> { | |
| const url = new URL(req.url ?? "/", "http://localhost"); | |
| const path = normalizeWebhookPath(url.pathname); | |
| const targets = webhookTargets.get(path); | |
| if (!targets || targets.length === 0) { | |
| return false; | |
| } | |
| if (req.method !== "POST") { | |
| res.statusCode = 405; | |
| res.setHeader("Allow", "POST"); | |
| res.end("Method Not Allowed"); | |
| return true; | |
| } | |
| const authHeader = String(req.headers.authorization ?? ""); | |
| const bearer = authHeader.toLowerCase().startsWith("bearer ") | |
| ? authHeader.slice("bearer ".length) | |
| : ""; | |
| const body = await readJsonBody(req, 1024 * 1024); | |
| if (!body.ok) { | |
| res.statusCode = body.error === "payload too large" ? 413 : 400; | |
| res.end(body.error ?? "invalid payload"); | |
| return true; | |
| } | |
| let raw = body.value; | |
| if (!raw || typeof raw !== "object" || Array.isArray(raw)) { | |
| res.statusCode = 400; | |
| res.end("invalid payload"); | |
| return true; | |
| } | |
| // 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; | |
| raw = { | |
| type: "MESSAGE", | |
| space: messagePayload?.space, | |
| message: messagePayload?.message, | |
| user: chat.user, | |
| eventTime: chat.eventTime, | |
| }; | |
| // For Add-ons, the bearer token may be in authorizationEventObject.systemIdToken | |
| const systemIdToken = rawObj.authorizationEventObject?.systemIdToken; | |
| if (!bearer && systemIdToken) { | |
| Object.assign(req.headers, { authorization: `Bearer ${systemIdToken}` }); | |
| } | |
| } | |
| const event = raw as GoogleChatEvent; | |
| const eventType = event.type ?? (raw as { eventType?: string }).eventType; | |
| if (typeof eventType !== "string") { | |
| res.statusCode = 400; | |
| res.end("invalid payload"); | |
| return true; | |
| } | |
| if (!event.space || typeof event.space !== "object" || Array.isArray(event.space)) { | |
| res.statusCode = 400; | |
| res.end("invalid payload"); | |
| return true; | |
| } | |
| if (eventType === "MESSAGE") { | |
| if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) { | |
| res.statusCode = 400; | |
| res.end("invalid payload"); | |
| return true; | |
| } | |
| } | |
| // Re-extract bearer in case it was updated from Add-on format | |
| const authHeaderNow = String(req.headers.authorization ?? ""); | |
| const effectiveBearer = authHeaderNow.toLowerCase().startsWith("bearer ") | |
| ? authHeaderNow.slice("bearer ".length) | |
| : bearer; | |
| let selected: WebhookTarget | undefined; | |
| for (const target of targets) { | |
| const audienceType = target.audienceType; | |
| const audience = target.audience; | |
| const verification = await verifyGoogleChatRequest({ | |
| bearer: effectiveBearer, | |
| audienceType, | |
| audience, | |
| }); | |
| if (verification.ok) { | |
| selected = target; | |
| break; | |
| } | |
| } | |
| if (!selected) { | |
| res.statusCode = 401; | |
| res.end("unauthorized"); | |
| return true; | |
| } | |
| selected.statusSink?.({ lastInboundAt: Date.now() }); | |
| processGoogleChatEvent(event, selected).catch((err) => { | |
| selected?.runtime.error?.( | |
| `[${selected.account.accountId}] Google Chat webhook failed: ${String(err)}`, | |
| ); | |
| }); | |
| res.statusCode = 200; | |
| res.setHeader("Content-Type", "application/json"); | |
| res.end("{}"); | |
| return true; | |
| } | |
| 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, | |
| }); | |
| } | |
| function normalizeUserId(raw?: string | null): string { | |
| const trimmed = raw?.trim() ?? ""; | |
| if (!trimmed) { | |
| return ""; | |
| } | |
| return trimmed.replace(/^users\//i, "").toLowerCase(); | |
| } | |
| export function isSenderAllowed( | |
| senderId: string, | |
| senderEmail: string | undefined, | |
| allowFrom: string[], | |
| ) { | |
| if (allowFrom.includes("*")) { | |
| return true; | |
| } | |
| const normalizedSenderId = normalizeUserId(senderId); | |
| const normalizedEmail = senderEmail?.trim().toLowerCase() ?? ""; | |
| return allowFrom.some((entry) => { | |
| const normalized = String(entry).trim().toLowerCase(); | |
| if (!normalized) { | |
| return false; | |
| } | |
| if (normalized === normalizedSenderId) { | |
| return true; | |
| } | |
| if (normalizedEmail && normalized === normalizedEmail) { | |
| return true; | |
| } | |
| if (normalizedEmail && normalized.replace(/^users\//i, "") === normalizedEmail) { | |
| return true; | |
| } | |
| if (normalized.replace(/^users\//i, "") === normalizedSenderId) { | |
| return true; | |
| } | |
| if (normalized.replace(/^(googlechat|google-chat|gchat):/i, "") === normalizedSenderId) { | |
| return true; | |
| } | |
| return false; | |
| }); | |
| } | |
| function resolveGroupConfig(params: { | |
| groupId: string; | |
| groupName?: string | null; | |
| groups?: Record< | |
| string, | |
| { | |
| requireMention?: boolean; | |
| allow?: boolean; | |
| enabled?: boolean; | |
| users?: Array<string | number>; | |
| systemPrompt?: string; | |
| } | |
| >; | |
| }) { | |
| const { groupId, groupName, groups } = params; | |
| const entries = groups ?? {}; | |
| const keys = Object.keys(entries); | |
| if (keys.length === 0) { | |
| return { entry: undefined, allowlistConfigured: false }; | |
| } | |
| const normalizedName = groupName?.trim().toLowerCase(); | |
| const candidates = [groupId, groupName ?? "", normalizedName ?? ""].filter(Boolean); | |
| let entry = candidates.map((candidate) => entries[candidate]).find(Boolean); | |
| if (!entry && normalizedName) { | |
| entry = entries[normalizedName]; | |
| } | |
| const fallback = entries["*"]; | |
| return { entry: entry ?? fallback, allowlistConfigured: true, fallback }; | |
| } | |
| 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 }; | |
| } | |
| /** | |
| * 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 defaultGroupPolicy = config.channels?.defaults?.groupPolicy; | |
| const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; | |
| 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 (groupPolicy === "disabled") { | |
| logVerbose(core, runtime, `drop group message (groupPolicy=disabled, space=${spaceId})`); | |
| return; | |
| } | |
| const groupAllowlistConfigured = groupConfigResolved.allowlistConfigured; | |
| const groupAllowed = Boolean(groupEntry) || Boolean((account.config.groups ?? {})["*"]); | |
| if (groupPolicy === "allowlist") { | |
| if (!groupAllowlistConfigured) { | |
| logVerbose( | |
| core, | |
| runtime, | |
| `drop group message (groupPolicy=allowlist, no allowlist, space=${spaceId})`, | |
| ); | |
| return; | |
| } | |
| if (!groupAllowed) { | |
| logVerbose(core, runtime, `drop group message (not allowlisted, space=${spaceId})`); | |
| return; | |
| } | |
| } | |
| if (groupEntry?.enabled === false || groupEntry?.allow === false) { | |
| logVerbose(core, runtime, `drop group message (space disabled, space=${spaceId})`); | |
| return; | |
| } | |
| if (groupUsers.length > 0) { | |
| const ok = isSenderAllowed( | |
| senderId, | |
| senderEmail, | |
| groupUsers.map((v) => String(v)), | |
| ); | |
| if (!ok) { | |
| logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`); | |
| return; | |
| } | |
| } | |
| } | |
| const dmPolicy = account.config.dm?.policy ?? "pairing"; | |
| const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v)); | |
| const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config); | |
| const storeAllowFrom = | |
| !isGroup && (dmPolicy !== "open" || shouldComputeAuth) | |
| ? await core.channel.pairing.readAllowFromStore("googlechat").catch(() => []) | |
| : []; | |
| const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]; | |
| const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom; | |
| const useAccessGroups = config.commands?.useAccessGroups !== false; | |
| const senderAllowedForCommands = isSenderAllowed(senderId, senderEmail, commandAllowFrom); | |
| 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 mentionGate = resolveMentionGatingWithBypass({ | |
| isGroup: true, | |
| requireMention, | |
| canDetectMention: true, | |
| wasMentioned: mentionInfo.wasMentioned, | |
| implicitMention: false, | |
| hasAnyMention: mentionInfo.hasAnyMention, | |
| allowTextCommands, | |
| hasControlCommand: core.channel.text.hasControlCommand(rawBody, config), | |
| commandAuthorized: commandAuthorized === true, | |
| }); | |
| effectiveWasMentioned = mentionGate.effectiveWasMentioned; | |
| if (mentionGate.shouldSkip) { | |
| logVerbose(core, runtime, `drop group message (mention required, space=${spaceId})`); | |
| return; | |
| } | |
| } | |
| if (!isGroup) { | |
| if (dmPolicy === "disabled" || account.config.dm?.enabled === false) { | |
| logVerbose(core, runtime, `Blocked Google Chat DM from ${senderId} (dmPolicy=disabled)`); | |
| return; | |
| } | |
| if (dmPolicy !== "open") { | |
| const allowed = senderAllowedForCommands; | |
| if (!allowed) { | |
| if (dmPolicy === "pairing") { | |
| const { code, created } = await core.channel.pairing.upsertPairingRequest({ | |
| channel: "googlechat", | |
| id: senderId, | |
| meta: { name: senderName || undefined, email: senderEmail }, | |
| }); | |
| if (created) { | |
| logVerbose(core, runtime, `googlechat pairing request sender=${senderId}`); | |
| try { | |
| await sendGoogleChatMessage({ | |
| account, | |
| space: spaceId, | |
| text: core.channel.pairing.buildPairingReply({ | |
| channel: "googlechat", | |
| idLine: `Your Google Chat user id: ${senderId}`, | |
| code, | |
| }), | |
| }); | |
| statusSink?.({ lastOutboundAt: Date.now() }); | |
| } catch (err) { | |
| logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`); | |
| } | |
| } | |
| } else { | |
| logVerbose( | |
| core, | |
| runtime, | |
| `Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`, | |
| ); | |
| } | |
| return; | |
| } | |
| } | |
| } | |
| if ( | |
| isGroup && | |
| core.channel.commands.isControlCommandMessage(rawBody, config) && | |
| commandAuthorized !== true | |
| ) { | |
| logVerbose(core, runtime, `googlechat: drop control command from ${senderId}`); | |
| return; | |
| } | |
| const route = core.channel.routing.resolveAgentRoute({ | |
| cfg: config, | |
| channel: "googlechat", | |
| accountId: account.accountId, | |
| peer: { | |
| kind: isGroup ? "group" : "dm", | |
| id: spaceId, | |
| }, | |
| }); | |
| 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 = core.channel.session.resolveStorePath(config.session?.store, { | |
| agentId: route.agentId, | |
| }); | |
| const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); | |
| const previousTimestamp = core.channel.session.readSessionUpdatedAt({ | |
| storePath, | |
| sessionKey: route.sessionKey, | |
| }); | |
| const body = core.channel.reply.formatAgentEnvelope({ | |
| channel: "Google Chat", | |
| from: fromLabel, | |
| timestamp: event.eventTime ? Date.parse(event.eventTime) : undefined, | |
| previousTimestamp, | |
| envelope: envelopeOptions, | |
| body: rawBody, | |
| }); | |
| const groupSystemPrompt = groupConfigResolved.entry?.systemPrompt?.trim() || undefined; | |
| const ctxPayload = core.channel.reply.finalizeInboundContext({ | |
| Body: body, | |
| 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)}`); | |
| } | |
| } | |
| await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ | |
| ctx: ctxPayload, | |
| cfg: config, | |
| dispatcherOptions: { | |
| 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)}`, | |
| ); | |
| }, | |
| }, | |
| }); | |
| } | |
| 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 mediaList = payload.mediaUrls?.length | |
| ? payload.mediaUrls | |
| : payload.mediaUrl | |
| ? [payload.mediaUrl] | |
| : []; | |
| if (mediaList.length > 0) { | |
| let suppressCaption = false; | |
| if (typingMessageName) { | |
| try { | |
| await deleteGoogleChatMessage({ | |
| account, | |
| messageName: typingMessageName, | |
| }); | |
| } catch (err) { | |
| runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`); | |
| const fallbackText = payload.text?.trim() | |
| ? payload.text | |
| : mediaList.length > 1 | |
| ? "Sent attachments." | |
| : "Sent attachment."; | |
| try { | |
| await updateGoogleChatMessage({ | |
| account, | |
| messageName: typingMessageName, | |
| text: fallbackText, | |
| }); | |
| suppressCaption = Boolean(payload.text?.trim()); | |
| } catch (updateErr) { | |
| runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`); | |
| } | |
| } | |
| } | |
| let first = true; | |
| for (const mediaUrl of mediaList) { | |
| const caption = first && !suppressCaption ? payload.text : undefined; | |
| first = false; | |
| try { | |
| const loaded = await core.channel.media.fetchRemoteMedia(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)}`); | |
| } | |
| } | |
| return; | |
| } | |
| if (payload.text) { | |
| const chunkLimit = account.config.textChunkLimit ?? 4000; | |
| const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId); | |
| const chunks = core.channel.text.chunkMarkdownTextWithMode(payload.text, chunkLimit, chunkMode); | |
| for (let i = 0; i < chunks.length; i++) { | |
| const chunk = chunks[i]; | |
| try { | |
| // Edit typing message with first chunk if available | |
| if (i === 0 && typingMessageName) { | |
| await updateGoogleChatMessage({ | |
| account, | |
| messageName: typingMessageName, | |
| text: chunk, | |
| }); | |
| } else { | |
| await sendGoogleChatMessage({ | |
| account, | |
| space: spaceId, | |
| text: chunk, | |
| thread: payload.replyToId, | |
| }); | |
| } | |
| statusSink?.({ lastOutboundAt: Date.now() }); | |
| } catch (err) { | |
| runtime.error?.(`Google Chat message 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; | |
| const { uploadGoogleChatAttachment } = await import("./api.js"); | |
| return await uploadGoogleChatAttachment({ | |
| account, | |
| space: spaceId, | |
| filename, | |
| buffer, | |
| contentType, | |
| }); | |
| } | |
| export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): () => void { | |
| const core = getGoogleChatRuntime(); | |
| const webhookPath = resolveWebhookPath(options.webhookPath, options.webhookUrl); | |
| 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 unregister = registerGoogleChatWebhookTarget({ | |
| account: options.account, | |
| config: options.config, | |
| runtime: options.runtime, | |
| core, | |
| path: webhookPath, | |
| audienceType, | |
| audience, | |
| statusSink: options.statusSink, | |
| mediaMaxMb, | |
| }); | |
| return unregister; | |
| } | |
| export async function startGoogleChatMonitor( | |
| params: GoogleChatMonitorOptions, | |
| ): Promise<() => void> { | |
| return monitorGoogleChatProvider(params); | |
| } | |
| export function resolveGoogleChatWebhookPath(params: { | |
| account: ResolvedGoogleChatAccount; | |
| }): string { | |
| return ( | |
| resolveWebhookPath(params.account.config.webhookPath, params.account.config.webhookUrl) ?? | |
| "/googlechat" | |
| ); | |
| } | |
| export function computeGoogleChatMediaMaxMb(params: { account: ResolvedGoogleChatAccount }) { | |
| return params.account.config.mediaMaxMb ?? 20; | |
| } | |