| import type { IncomingMessage, ServerResponse } from "node:http"; |
|
|
| import type { OpenClawConfig } from "openclaw/plugin-sdk"; |
| import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk"; |
|
|
| 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"; |
| import type { |
| GoogleChatAnnotation, |
| GoogleChatAttachment, |
| GoogleChatEvent, |
| GoogleChatSpace, |
| GoogleChatMessage, |
| GoogleChatUser, |
| } from "./types.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; |
| } |
|
|
| |
| 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, |
| }; |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| 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 }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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)}`); |
| }); |
|
|
| |
| |
| |
| 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; |
|
|
| |
| 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, |
| }); |
| |
| 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 { |
| |
| 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; |
| } |
|
|