UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,121 lines (1,120 loc) 42 kB
import { a as normalizeLowercaseStringOrEmpty, c as normalizeOptionalString, s as normalizeOptionalLowercaseString } from "./string-coerce-mnp54Vah.js"; import { l as normalizeStringEntries } from "./string-normalization-WNUDCpXX.js"; import { _ as resolveRequestClientIp } from "./net-DTe7AQiu.js"; import { i as deliverTextOrMediaReply, m as resolveSendableOutboundReplyParts } from "./reply-payload-D-VMfpYI.js"; import { a as warnMissingProviderGroupPolicyFallbackOnce, n as resolveAllowlistProviderRuntimeGroupPolicy, r as resolveDefaultGroupPolicy, t as GROUP_POLICY_BLOCKED_LABEL } from "./runtime-group-policy-CSD1Is8G.js"; import "./string-coerce-runtime-CEGJWkQ_.js"; import { v as recordChannelBotPairLoopAndCheckSuppression } from "./kernel-BdraY_qZ.js"; import { i as mergePairLoopGuardConfig } from "./pair-loop-guard-runtime-D0pZ_1is.js"; import { n as isDangerousNameMatchingEnabled } from "./dangerous-name-matching-D4QRC91G.js"; import { t as resolveApprovalOverGateway } from "./approval-gateway-resolver-kRkWoLay.js"; import "./approval-gateway-runtime-D7fO1U7E.js"; import { r as resolveInboundRouteEnvelopeBuilderWithRuntime } from "./inbound-envelope-DF4N8x5q.js"; import "./channel-inbound-bl7VmTdr.js"; import { n as createChannelIngressResolver, o as defineStableChannelIngressIdentity, t as channelIngressRoutes } from "./message-access-mwxdTUGC.js"; import "./channel-ingress-runtime-rGltVkKC.js"; import { n as createChannelPairingController } from "./channel-pairing-Bk6_JhTm.js"; import { a as createWebhookInFlightLimiter, s as readJsonWebhookBodyOrReject } from "./webhook-request-guards-gS2khsvD.js"; import { a as createFixedWindowRateLimiter, r as WEBHOOK_RATE_LIMIT_DEFAULTS } from "./webhook-ingress-Dgd3Jtks.js"; import { n as resolveWebhookPath, t as normalizeWebhookPath } from "./webhook-path-CaYfbDPb.js"; import { l as withResolvedWebhookRequestPipeline, n as registerWebhookTargetWithPluginRoute, o as resolveWebhookTargetWithAuthOrReject } from "./webhook-targets-Beqkt_FW.js"; import { _ as claimGoogleChatApprovalCardBinding, b as getGoogleChatApprovalCardBinding, c as downloadGoogleChatMedia, d as sendGoogleChatMessage, f as updateGoogleChatMessage, m as verifyGoogleChatRequest, o as deleteGoogleChatMessage, p as uploadGoogleChatAttachment, u as probeGoogleChat, v as completeGoogleChatApprovalCardBinding, w as releaseGoogleChatApprovalCardBinding, x as readGoogleChatApprovalActionToken } from "./targets-BIe3h2UJ.js"; import { t as getGoogleChatRuntime } from "./runtime-api-Dp_Hh2eL.js"; import { n as googleChatApprovalAuth } from "./approval-auth-USWn0b35.js"; //#region extensions/googlechat/src/approval-card-click.ts function logIgnored(target, message) { target.runtime.log?.(`[${target.account.accountId}] googlechat approval ignored: ${message}`); } async function maybeHandleGoogleChatApprovalCardClick(params) { if ((params.event.type ?? params.event.eventType) !== "CARD_CLICKED") return false; const token = readGoogleChatApprovalActionToken(params.event); if (!token) return false; const binding = getGoogleChatApprovalCardBinding(token); if (!binding) { logIgnored(params.target, "unknown or expired card token"); return true; } if (binding.accountId !== params.target.account.accountId) { logIgnored(params.target, "card token account mismatch"); return true; } if (params.event.space?.name !== binding.spaceName) { logIgnored(params.target, "card token space mismatch"); return true; } if (params.event.message?.name && params.event.message.name !== binding.messageName) { logIgnored(params.target, "card token message mismatch"); return true; } if (!binding.allowedDecisions.includes(binding.decision)) { logIgnored(params.target, "card token decision is no longer allowed"); return true; } const actor = params.event.user?.name; if (!(googleChatApprovalAuth.authorizeActorAction?.({ cfg: params.target.config, accountId: params.target.account.accountId, senderId: actor, action: "approve", approvalKind: binding.approvalKind }))?.authorized) { logIgnored(params.target, `unauthorized actor ${actor || "unknown"}`); return true; } const claim = claimGoogleChatApprovalCardBinding(token); if (claim.kind === "missing") { logIgnored(params.target, "card token already consumed"); return true; } if (claim.kind === "in-flight") { logIgnored(params.target, "card token resolve already in flight"); return true; } const consumed = claim.binding; try { await resolveApprovalOverGateway({ cfg: params.target.config, approvalId: consumed.approvalId, decision: consumed.decision, senderId: actor, allowPluginFallback: consumed.approvalKind === "exec", clientDisplayName: `Google Chat approval (${actor?.trim() || "unknown"})` }); } catch (error) { releaseGoogleChatApprovalCardBinding(token); throw error; } completeGoogleChatApprovalCardBinding(token); params.target.runtime.log?.(`[${params.target.account.accountId}] googlechat approval resolved id=${consumed.approvalId} decision=${consumed.decision} sender=${actor || "unknown"}`); return true; } //#endregion //#region extensions/googlechat/src/monitor-access.ts function normalizeUserId(raw) { const trimmed = normalizeOptionalString(raw) ?? ""; if (!trimmed) return ""; return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, "")); } const GOOGLECHAT_EMAIL_KIND = "plugin:googlechat-email"; function normalizeEntryValue(raw) { return normalizeLowercaseStringOrEmpty(raw ?? ""); } function normalizeGoogleChatStableEntry(entry) { const withoutProvider = normalizeEntryValue(entry).replace(/^(googlechat|google-chat|gchat):/i, ""); if (!withoutProvider) return null; return withoutProvider.startsWith("users/") ? normalizeUserId(withoutProvider) : withoutProvider; } function normalizeGoogleChatEmailEntry(entry) { if (normalizeEntryValue(entry).replace(/^(googlechat|google-chat|gchat):/i, "").startsWith("users/")) return null; const stable = normalizeGoogleChatStableEntry(entry); return stable?.includes("@") ? stable : null; } const googleChatIngressIdentity = defineStableChannelIngressIdentity({ key: "sender-id", normalizeEntry: normalizeGoogleChatStableEntry, normalizeSubject: normalizeUserId, aliases: [{ key: "email", kind: GOOGLECHAT_EMAIL_KIND, normalizeEntry: normalizeGoogleChatEmailEntry, normalizeSubject: normalizeEntryValue, dangerous: true }], isWildcardEntry: (entry) => normalizeEntryValue(entry) === "*", resolveEntryId: ({ entryIndex, fieldKey }) => fieldKey === "stableId" ? `entry-${entryIndex + 1}:user` : `entry-${entryIndex + 1}:${fieldKey}` }); function resolveGroupConfig(params) { const { groupId, groupName, groups } = params; const entries = groups ?? {}; const keys = Object.keys(entries); if (keys.length === 0) return { entry: void 0, 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 ? void 0 : entry ?? fallback, allowlistConfigured: true, fallback, deprecatedNameMatch }; } function extractMentionInfo(annotations, botUser) { const mentionAnnotations = annotations.filter((entry) => entry.type === "USER_MENTION"); const hasAnyMention = mentionAnnotations.length > 0; const botTargets = new Set(["users/app", botUser?.trim()].filter(Boolean)); return { hasAnyMention, wasMentioned: mentionAnnotations.some((entry) => { const userName = entry.userMention?.user?.name; if (!userName) return false; if (botTargets.has(userName)) return true; return normalizeUserId(userName) === "app"; }) }; } const warnedDeprecatedUsersEmailAllowFrom = /* @__PURE__ */ new Set(); const warnedMutableGroupKeys = /* @__PURE__ */ new Set(); function warnDeprecatedUsersEmailEntries(logVerbose, entries) { const deprecated = entries.map((v) => normalizeOptionalString(v)).filter((v) => 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, groups) { 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(", ")}`); } async function applyGoogleChatInboundAccessPolicy(params) { 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 !== void 0, 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 ?? void 0); const groupConfigResolved = resolveGroupConfig({ groupId: spaceId, groupName: space.displayName ?? null, groups: account.config.groups ?? void 0 }); const groupEntry = groupConfigResolved.entry; const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? []; let effectiveWasMentioned; const dmPolicy = account.config.dm?.policy ?? "pairing"; const rawConfigAllowFrom = normalizeStringEntries(account.config.dm?.allowFrom); const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config); const groupActivation = (() => { if (!isGroup) return; const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true; const mentionInfo = extractMentionInfo(message.annotations ?? [], account.config.botUser); return { requireMention, allowTextCommands: core.channel.commands.shouldHandleTextCommands({ cfg: config, surface: "googlechat" }), hasControlCommand: core.channel.text.hasControlCommand(rawBody, config), wasMentioned: mentionInfo.wasMentioned, hasAnyMention: mentionInfo.hasAnyMention }; })(); const command = { hasControlCommand: groupActivation?.hasControlCommand ?? shouldComputeAuth, groupOwnerAllowFrom: "none" }; const groupAllowFrom = normalizeStringEntries(groupUsers); const senderGroupPolicy = groupConfigResolved.allowlistConfigured && groupAllowFrom.length === 0 ? groupPolicy : groupPolicy === "disabled" ? "disabled" : groupAllowFrom.length > 0 ? "allowlist" : "open"; const route = channelIngressRoutes(isGroup && groupPolicy !== "disabled" && groupEntry?.enabled === false && { id: "googlechat:space", enabled: false, matched: true, matchId: "googlechat-space", blockReason: "route_disabled" }, isGroup && groupPolicy === "allowlist" && groupEntry?.enabled !== false && !groupConfigResolved.allowlistConfigured && { id: "googlechat:space", allowed: false, blockReason: "empty_allowlist" }, isGroup && groupPolicy === "allowlist" && groupEntry?.enabled !== false && groupConfigResolved.allowlistConfigured && { id: "googlechat:space", senderPolicy: "deny-when-empty", ...groupEntry ? { senderAllowFromSource: "effective-group" } : {}, allowed: Boolean(groupEntry), matchId: "googlechat-space", blockReason: groupEntry ? "sender_empty_allowlist" : "route_not_allowlisted" }); const resolvedAccess = await createChannelIngressResolver({ channelId: "googlechat", accountId: account.accountId, identity: googleChatIngressIdentity, cfg: config, readStoreAllowFrom: pairing.readAllowFromStore }).message({ subject: { stableId: senderId, aliases: { email: senderEmail } }, conversation: { kind: isGroup ? "group" : "direct", id: spaceId }, route, allowFrom: rawConfigAllowFrom, groupAllowFrom, dmPolicy, groupPolicy: senderGroupPolicy, policy: { groupAllowFromFallbackToAllowFrom: false, mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled", ...groupActivation ? { activation: { requireMention: groupActivation.requireMention, allowTextCommands: groupActivation.allowTextCommands } } : {} }, ...groupActivation == null ? {} : { mentionFacts: { canDetectMention: true, wasMentioned: groupActivation.wasMentioned, hasAnyMention: groupActivation.hasAnyMention, implicitMentionKinds: [] } }, command }); const senderAccess = resolvedAccess.senderAccess; const commandAuthorized = resolvedAccess.commandAccess.requested ? resolvedAccess.commandAccess.authorized : void 0; if (isGroup) { if (groupConfigResolved.deprecatedNameMatch) { logVerbose(`drop group message (deprecated mutable group key matched, space=${spaceId})`); return { ok: false }; } const routeBlockReason = resolvedAccess.routeAccess.reason; if (routeBlockReason && routeBlockReason !== "sender_empty_allowlist") { if (routeBlockReason === "empty_allowlist") logVerbose(`drop group message (groupPolicy=allowlist, no allowlist, space=${spaceId})`); else if (routeBlockReason === "route_not_allowlisted") logVerbose(`drop group message (not allowlisted, space=${spaceId})`); else if (routeBlockReason === "route_disabled") logVerbose(`drop group message (space disabled, space=${spaceId})`); return { ok: false }; } if (senderAccess.effectiveGroupAllowFrom.length > 0 && senderAccess.decision !== "allow") { warnDeprecatedUsersEmailEntries(logVerbose, senderAccess.effectiveGroupAllowFrom); logVerbose(`drop group message (sender not allowed, ${senderId})`); return { ok: false }; } } const effectiveAllowFrom = senderAccess.effectiveAllowFrom; warnDeprecatedUsersEmailEntries(logVerbose, effectiveAllowFrom); if (isGroup && resolvedAccess.activationAccess.ran) { effectiveWasMentioned = resolvedAccess.activationAccess.effectiveWasMentioned; if (resolvedAccess.activationAccess.shouldSkip) { logVerbose(`drop group message (mention required, space=${spaceId})`); return { ok: false }; } } if (isGroup && senderAccess.decision !== "allow") { logVerbose(`drop group message (sender policy blocked, reason=${resolvedAccess.ingress.reasonCode === "route_sender_empty" ? "groupPolicy=allowlist (empty allowlist)" : senderAccess.reasonCode}, 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 (senderAccess.decision !== "allow") { if (senderAccess.decision === "pairing") await pairing.issueChallenge({ senderId, senderIdLine: `Your Google Chat user id: ${senderId}`, meta: { name: senderName || void 0, 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, groupBotLoopProtection: groupEntry?.botLoopProtection, groupSystemPrompt: normalizeOptionalString(groupEntry?.systemPrompt) }; } //#endregion //#region extensions/googlechat/src/monitor-durable.ts function resolveGoogleChatDurableReplyOptions(params) { if (params.infoKind !== "final" || params.typingMessageName) return false; const threadId = params.payload.replyToId?.trim() || void 0; return { to: params.spaceId, ...threadId ? { replyToId: threadId, threadId } : {} }; } //#endregion //#region extensions/googlechat/src/monitor-reply-delivery.ts async function deliverGoogleChatReply(params) { const { payload, account, spaceId, runtime, core, config, statusSink } = params; let typingMessageName = params.typingMessageName; const reply = resolveSendableOutboundReplyParts(payload); const mediaCount = reply.mediaCount; const hasMedia = reply.hasMedia; const text = reply.text; let firstTextChunk = true; let suppressCaption = false; if (hasMedia && typingMessageName) try { await deleteGoogleChatMessage({ account, messageName: typingMessageName }); typingMessageName = void 0; } catch (err) { runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`); if (typingMessageName) { 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)}`); typingMessageName = void 0; } } } const chunkLimit = account.config.textChunkLimit ?? 4e3; const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId); const sendTextMessage = async (chunk) => { await sendGoogleChatMessage({ account, space: spaceId, text: chunk, thread: payload.replyToId }); }; 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 sendTextMessage(chunk); firstTextChunk = false; statusSink?.({ lastOutboundAt: Date.now() }); } catch (err) { runtime.error?.(`Google Chat message send failed: ${String(err)}`); if (firstTextChunk && typingMessageName) { typingMessageName = void 0; try { await sendTextMessage(chunk); statusSink?.({ lastOutboundAt: Date.now() }); } catch (fallbackErr) { runtime.error?.(`Google Chat message fallback send failed: ${String(fallbackErr)}`); } finally { firstTextChunk = false; } } } }, sendMedia: async ({ mediaUrl, caption }) => { try { const loaded = await core.channel.media.readRemoteMediaBuffer({ 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) { const { account, spaceId, buffer, contentType, filename } = params; return await uploadGoogleChatAttachment({ account, space: spaceId, filename, buffer, contentType }); } //#endregion //#region extensions/googlechat/src/monitor-webhook.ts function extractBearerToken(header) { const authHeader = Array.isArray(header) ? typeof header[0] === "string" ? header[0] : "" : typeof header === "string" ? header : ""; return normalizeLowercaseStringOrEmpty(authHeader).startsWith("bearer ") ? authHeader.slice(7).trim() : ""; } const ADD_ON_PREAUTH_MAX_BYTES = 16 * 1024; const ADD_ON_PREAUTH_TIMEOUT_MS = 3e3; function recordParamsToActionParameters(params) { if (!params) return; const entries = Object.entries(params).filter((entry) => typeof entry[1] === "string").map(([key, value]) => ({ key, value })); return entries.length > 0 ? entries : void 0; } function parseGoogleChatInboundPayload(raw, res) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { res.statusCode = 400; res.end("invalid payload"); return { ok: false }; } let eventPayload = raw; let addOnBearerToken = ""; const rawObj = raw; if (rawObj.commonEventObject?.hostApp === "CHAT") addOnBearerToken = typeof rawObj.authorizationEventObject?.systemIdToken === "string" ? rawObj.authorizationEventObject.systemIdToken.trim() : ""; if (rawObj.commonEventObject?.hostApp === "CHAT" && rawObj.chat?.messagePayload) { const chat = rawObj.chat; const messagePayload = chat.messagePayload; eventPayload = { type: "MESSAGE", space: messagePayload?.space, message: messagePayload?.message, user: chat.user, eventTime: chat.eventTime }; } else if (rawObj.commonEventObject?.hostApp === "CHAT") { const chat = rawObj.chat; const buttonClickedPayload = chat?.buttonClickedPayload; if (buttonClickedPayload) { const invokedFunction = rawObj.commonEventObject.invokedFunction; const actionParameters = recordParamsToActionParameters(rawObj.commonEventObject.parameters); eventPayload = { type: "CARD_CLICKED", space: buttonClickedPayload.space, message: buttonClickedPayload.message, user: buttonClickedPayload.user ?? chat.user, eventTime: chat.eventTime, action: buttonClickedPayload.action ?? { ...typeof invokedFunction === "string" ? { actionMethodName: invokedFunction } : {}, ...actionParameters ? { parameters: actionParameters } : {} }, commonEventObject: { ...typeof invokedFunction === "string" ? { invokedFunction } : {}, parameters: rawObj.commonEventObject.parameters } }; } } const event = eventPayload; const eventType = event.type ?? eventPayload.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 }; } } else if (eventType === "CARD_CLICKED") { if (!event.user || typeof event.user !== "object" || Array.isArray(event.user)) { res.statusCode = 400; res.end("invalid payload"); return { ok: false }; } } return { ok: true, event, addOnBearerToken }; } async function verifyGoogleChatTargetAuth(target, bearer) { const verification = await verifyGoogleChatRequest({ bearer, audienceType: target.audienceType, audience: target.audience, expectedAddOnPrincipal: target.account.config.appPrincipal }); return verification.ok ? { ok: true } : { ok: false, reason: verification.reason ?? "unknown" }; } function logGoogleChatWebhookAuthRejections(rejections) { for (const rejection of rejections) rejection.target.runtime.log?.(`[${rejection.target.account.accountId}] Google Chat webhook auth rejected: ${rejection.reason}`); } function logGoogleChatWebhookAuthRejectedForTargets(targets, reason) { logGoogleChatWebhookAuthRejections(targets.map((target) => ({ target, reason }))); } async function resolveGoogleChatWebhookTargetWithAuthOrReject(params) { const rejections = []; let verifiedTargetCount = 0; const selectedTarget = await resolveWebhookTargetWithAuthOrReject({ targets: params.targets, res: params.res, isMatch: async (target) => { const verification = await verifyGoogleChatTargetAuth(target, params.bearer); if (verification.ok) { verifiedTargetCount += 1; return true; } rejections.push({ target, reason: verification.reason }); return false; } }); if (!selectedTarget && verifiedTargetCount === 0) logGoogleChatWebhookAuthRejections(rejections); return selectedTarget; } function warnAppPrincipalMisconfiguration(params) { if (params.audienceType !== "app-url") return; const principal = params.appPrincipal?.trim(); if (!principal) params.log?.(`[${params.accountId}] appPrincipal is missing for audienceType "app-url"; add-on token verification will fail. Set appPrincipal to the numeric OAuth 2.0 client ID (uniqueId, 21 digits), not an email.`); else if (principal.includes("@")) params.log?.(`[${params.accountId}] appPrincipal "${principal}" looks like an email address. Set appPrincipal to the numeric OAuth 2.0 client ID (uniqueId, 21 digits), not an email.`); } function createGoogleChatWebhookRequestHandler(params) { return async (req, res) => { const path = normalizeWebhookPath(new URL(req.url ?? "/", "http://localhost").pathname); const config = params.webhookTargets.get(path)?.[0]?.config; const clientIp = resolveRequestClientIp(req, config?.gateway?.trustedProxies, config?.gateway?.allowRealIpFallback === true) ?? "unknown"; return await withResolvedWebhookRequestPipeline({ req, res, targetsByPath: params.webhookTargets, allowMethods: ["POST"], requireJsonContentType: true, rateLimiter: params.webhookRateLimiter, rateLimitKey: `${path}:${clientIp}`, inFlightLimiter: params.webhookInFlightLimiter, handle: async ({ targets }) => { const headerBearer = extractBearerToken(req.headers.authorization); let selectedTarget; let parsedEvent; const readAndParseEvent = async (profile) => { 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 resolveGoogleChatWebhookTargetWithAuthOrReject({ targets, res, bearer: headerBearer }); 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) { logGoogleChatWebhookAuthRejectedForTargets(targets, "missing token"); res.statusCode = 401; res.end("unauthorized"); return true; } selectedTarget = await resolveGoogleChatWebhookTargetWithAuthOrReject({ targets, res, bearer: parsed.addOnBearerToken }); 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; } }); }; } //#endregion //#region extensions/googlechat/src/monitor-routing.ts const webhookTargets = /* @__PURE__ */ new Map(); const webhookRateLimiter = createFixedWindowRateLimiter({ windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs, maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests, maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys }); const webhookInFlightLimiter = createWebhookInFlightLimiter(); let processGoogleChatEvent$1 = async () => {}; function setGoogleChatWebhookEventProcessor(processEvent) { processGoogleChatEvent$1 = processEvent; } const googleChatWebhookRequestHandler = createGoogleChatWebhookRequestHandler({ webhookTargets, webhookRateLimiter, webhookInFlightLimiter, processEvent: async (event, target) => { await processGoogleChatEvent$1(event, target); } }); function registerGoogleChatWebhookTarget(target) { 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) => { if (!await handleGoogleChatWebhookRequest(req, res) && !res.headersSent) { res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Not Found"); } } } }).unregister; } async function handleGoogleChatWebhookRequest(req, res) { return await googleChatWebhookRequestHandler(req, res); } //#endregion //#region extensions/googlechat/src/monitor.ts setGoogleChatWebhookEventProcessor(processGoogleChatEvent); function logVerbose(core, runtime, message) { if (core.logging.shouldLogVerbose()) runtime.log?.(`[googlechat] ${message}`); } function normalizeAudienceType(value) { 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"; } function resolveGoogleChatTimestampMs(eventTime) { if (!eventTime) return; const parsed = Date.parse(eventTime); return Number.isFinite(parsed) ? parsed : void 0; } function resolveGoogleChatBotLoopProtection(params) { if (!params.allowBots || !params.isBotSender || !params.senderId || params.senderId === params.appUserId) return; return { scopeId: params.accountId, conversationId: params.conversationId, senderId: params.senderId, receiverId: params.appUserId, config: params.config, defaultsConfig: params.defaultsConfig, defaultEnabled: true, nowMs: resolveGoogleChatTimestampMs(params.eventTime) }; } function resolveGoogleChatBotLoopProtectionConfig(params) { return mergePairLoopGuardConfig(params.accountConfig, params.groupConfig); } function shouldSuppressGoogleChatBotLoop(params) { if (!params.botLoopProtection) return false; if (!recordChannelBotPairLoopAndCheckSuppression(params.botLoopProtection).suppressed) return false; logVerbose(params.core, params.runtime, `skip bot-to-bot loop in ${params.botLoopProtection.conversationId}`); return true; } async function processGoogleChatEvent(event, target) { const eventType = event.type ?? event.eventType; if (eventType === "CARD_CLICKED") { await maybeHandleGoogleChatApprovalCardClick({ event, target }); return; } 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) { 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) { 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 isGroup = (space.type ?? "").toUpperCase() !== "DM"; const sender = message.sender ?? event.user; const senderId = sender?.name ?? ""; const senderName = sender?.displayName ?? ""; const senderEmail = sender?.email ?? void 0; const isBotSender = sender?.type?.toUpperCase() === "BOT"; const appUserId = account.config.botUser?.trim() || "users/app"; const allowBots = account.config.allowBots === true; if (!allowBots) { if (isBotSender) { 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: (messageLocal) => logVerbose(core, runtime, messageLocal) }); if (!access.ok) return; const { commandAuthorized, effectiveWasMentioned, groupBotLoopProtection, groupSystemPrompt } = access; if (shouldSuppressGoogleChatBotLoop({ botLoopProtection: resolveGoogleChatBotLoopProtection({ allowBots, isBotSender, senderId, appUserId, accountId: account.accountId, conversationId: spaceId, config: resolveGoogleChatBotLoopProtectionConfig({ accountConfig: account.config.botLoopProtection, groupConfig: groupBotLoopProtection }), defaultsConfig: config.channels?.defaults?.botLoopProtection, eventTime: event.eventTime }), core, runtime })) return; const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({ cfg: config, channel: "googlechat", accountId: account.accountId, peer: { kind: isGroup ? "group" : "direct", id: spaceId }, runtime: core.channel, sessionStore: config.session?.store }); let mediaPath; let mediaType; 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 timestampMs = resolveGoogleChatTimestampMs(event.eventTime); const { storePath, body } = buildEnvelope({ channel: "Google Chat", from: fromLabel, timestamp: timestampMs, body: rawBody }); const replyThreadName = isGroup ? message.thread?.name : void 0; const ctxPayload = core.channel.inbound.buildContext({ channel: "googlechat", accountId: route.accountId, messageId: message.name, messageIdFull: message.name, timestamp: timestampMs, from: `googlechat:${senderId}`, sender: { id: senderId, name: senderName || void 0, username: senderEmail }, conversation: { kind: isGroup ? "channel" : "direct", id: spaceId, label: fromLabel }, route: { agentId: route.agentId, accountId: route.accountId, routeSessionKey: route.sessionKey }, reply: { to: `googlechat:${spaceId}`, originatingTo: `googlechat:${spaceId}`, replyToId: replyThreadName, replyToIdFull: replyThreadName }, message: { body, bodyForAgent: rawBody, rawBody, commandBody: rawBody }, media: mediaPath || mediaType ? [{ path: mediaPath, url: mediaPath, contentType: mediaType }] : void 0, supplemental: { groupSystemPrompt: isGroup ? groupSystemPrompt : void 0 }, extra: { ChatType: isGroup ? "channel" : "direct", WasMentioned: isGroup ? effectiveWasMentioned : void 0, CommandAuthorized: commandAuthorized, GroupSubject: void 0, GroupSpace: isGroup ? space.displayName ?? void 0 : void 0 } }); 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; if (typingIndicator === "message") try { typingMessageName = (await sendGoogleChatMessage({ account, space: spaceId, text: `_${resolveBotDisplayName({ accountName: account.config.name, agentId: route.agentId, config })} is typing..._`, thread: replyThreadName }))?.messageName; } catch (err) { runtime.error?.(`Failed sending typing message: ${String(err)}`); } await core.channel.inbound.run({ channel: "googlechat", accountId: route.accountId, raw: message, adapter: { ingest: () => ({ id: message.name ?? spaceId, timestamp: timestampMs, rawText: rawBody, textForAgent: rawBody, textForCommands: rawBody, raw: message }), resolveTurn: () => ({ cfg: config, channel: "googlechat", accountId: route.accountId, agentId: route.agentId, routeSessionKey: route.sessionKey, storePath, ctxPayload, recordInboundSession: core.channel.session.recordInboundSession, dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { durable: (payload, info) => resolveGoogleChatDurableReplyOptions({ payload, infoKind: info.kind, spaceId, typingMessageName }), deliver: async (payload) => { await deliverGoogleChatReply({ payload, account, spaceId, runtime, core, config, statusSink, typingMessageName }); typingMessageName = void 0; }, onDelivered: () => { statusSink?.({ lastOutboundAt: Date.now() }); }, onError: (err, info) => { runtime.error?.(`[${account.accountId}] Google Chat ${info.kind} reply failed: ${String(err)}`); } }, replyPipeline: {}, record: { onRecordError: (err) => { runtime.error?.(`googlechat: failed updating session meta: ${String(err)}`); } } }) } }); } async function downloadAttachment(attachment, account, mediaMaxMb, core) { 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 }; } function monitorGoogleChatProvider(options) { 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; warnAppPrincipalMisconfiguration({ accountId: options.account.accountId, audienceType, appPrincipal: options.account.config.appPrincipal, log: options.runtime.log }); const unregisterTarget = registerGoogleChatWebhookTarget({ account: options.account, config: options.config, runtime: options.runtime, core, path: webhookPath, audienceType, audience, statusSink: options.statusSink, mediaMaxMb }); return () => { unregisterTarget(); }; } async function startGoogleChatMonitor(params) { return monitorGoogleChatProvider(params); } function resolveGoogleChatWebhookPath(params) { return resolveWebhookPath({ webhookPath: params.account.config.webhookPath, webhookUrl: params.account.config.webhookUrl, defaultPath: "/googlechat" }) ?? "/googlechat"; } //#endregion //#region extensions/googlechat/src/channel.runtime.ts const googleChatChannelRuntime = { probeGoogleChat, sendGoogleChatMessage, uploadGoogleChatAttachment, resolveGoogleChatWebhookPath, startGoogleChatMonitor }; //#endregion export { googleChatChannelRuntime };