import { GROUP_POLICY_BLOCKED_LABEL, createScopedPairingAccess, evaluateGroupRouteAccessForPolicy, issuePairingChallenge, isDangerousNameMatchingEnabled, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveDmGroupAccessWithLists, resolveMentionGatingWithBypass, resolveSenderScopedGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/googlechat"; import type { OpenClawConfig } from "openclaw/plugin-sdk/googlechat"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { sendGoogleChatMessage } from "./api.js"; import type { GoogleChatCoreRuntime } from "./monitor-types.js"; import type { GoogleChatAnnotation, GoogleChatMessage, GoogleChatSpace } from "./types.js"; function normalizeUserId(raw?: string | null): string { const trimmed = raw?.trim() ?? ""; if (!trimmed) { return ""; } return trimmed.replace(/^users\//i, "").toLowerCase(); } function isEmailLike(value: string): boolean { // Keep this intentionally loose; allowlists are user-provided config. return value.includes("@"); } export function isSenderAllowed( senderId: string, senderEmail: string | undefined, allowFrom: string[], allowNameMatching = false, ) { if (allowFrom.includes("*")) { return true; } const normalizedSenderId = normalizeUserId(senderId); const normalizedEmail = senderEmail?.trim().toLowerCase() ?? ""; return allowFrom.some((entry) => { const normalized = String(entry).trim().toLowerCase(); if (!normalized) { return false; } // Accept `googlechat:` but treat `users/...` as an *ID* only (deprecated `users/`). const withoutPrefix = normalized.replace(/^(googlechat|google-chat|gchat):/i, ""); if (withoutPrefix.startsWith("users/")) { return normalizeUserId(withoutPrefix) === normalizedSenderId; } // Raw email allowlist entries are a break-glass override. if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) { return withoutPrefix === normalizedEmail; } return withoutPrefix.replace(/^users\//i, "") === normalizedSenderId; }); } type GoogleChatGroupEntry = { requireMention?: boolean; allow?: boolean; enabled?: boolean; users?: Array; systemPrompt?: string; }; function resolveGroupConfig(params: { groupId: string; groupName?: string | null; groups?: Record; }) { 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 }; } const warnedDeprecatedUsersEmailAllowFrom = new Set(); function warnDeprecatedUsersEmailEntries(logVerbose: (message: string) => void, entries: string[]) { const deprecated = entries.map((v) => String(v).trim()).filter((v) => /^users\/.+@.+/i.test(v)); if (deprecated.length === 0) { return; } const key = deprecated .map((v) => v.toLowerCase()) .sort() .join(","); if (warnedDeprecatedUsersEmailAllowFrom.has(key)) { return; } warnedDeprecatedUsersEmailAllowFrom.add(key); logVerbose( `Deprecated allowFrom entry detected: "users/" is no longer treated as an email allowlist. Use raw email (alice@example.com) or immutable user id (users/). entries=${deprecated.join(", ")}`, ); } export async function applyGoogleChatInboundAccessPolicy(params: { account: ResolvedGoogleChatAccount; config: OpenClawConfig; core: GoogleChatCoreRuntime; space: GoogleChatSpace; message: GoogleChatMessage; isGroup: boolean; senderId: string; senderName: string; senderEmail?: string; rawBody: string; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; logVerbose: (message: string) => void; }): Promise< | { ok: true; commandAuthorized: boolean | undefined; effectiveWasMentioned: boolean | undefined; groupSystemPrompt: string | undefined; } | { ok: false } > { const { account, config, core, space, message, isGroup, senderId, senderName, senderEmail, rawBody, statusSink, logVerbose, } = params; const allowNameMatching = isDangerousNameMatchingEnabled(account.config); const spaceId = space.name ?? ""; const pairing = createScopedPairingAccess({ core, channel: "googlechat", accountId: account.accountId, }); const defaultGroupPolicy = resolveDefaultGroupPolicy(config); const { groupPolicy, providerMissingFallbackApplied } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: config.channels?.googlechat !== undefined, groupPolicy: account.config.groupPolicy, defaultGroupPolicy, }); warnMissingProviderGroupPolicyFallbackOnce({ providerMissingFallbackApplied, providerKey: "googlechat", accountId: account.accountId, blockedLabel: GROUP_POLICY_BLOCKED_LABEL.space, log: logVerbose, }); 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) { const groupAllowlistConfigured = groupConfigResolved.allowlistConfigured; const routeAccess = evaluateGroupRouteAccessForPolicy({ groupPolicy, routeAllowlistConfigured: groupAllowlistConfigured, routeMatched: Boolean(groupEntry), routeEnabled: groupEntry?.enabled !== false && groupEntry?.allow !== false, }); if (!routeAccess.allowed) { if (routeAccess.reason === "disabled") { logVerbose(`drop group message (groupPolicy=disabled, space=${spaceId})`); } else if (routeAccess.reason === "empty_allowlist") { logVerbose(`drop group message (groupPolicy=allowlist, no allowlist, space=${spaceId})`); } else if (routeAccess.reason === "route_not_allowlisted") { logVerbose(`drop group message (not allowlisted, space=${spaceId})`); } else if (routeAccess.reason === "route_disabled") { logVerbose(`drop group message (space disabled, space=${spaceId})`); } return { ok: false }; } if (groupUsers.length > 0) { const normalizedGroupUsers = groupUsers.map((v) => String(v)); warnDeprecatedUsersEmailEntries(logVerbose, normalizedGroupUsers); const ok = isSenderAllowed(senderId, senderEmail, normalizedGroupUsers, allowNameMatching); if (!ok) { logVerbose(`drop group message (sender not allowed, ${senderId})`); return { ok: false }; } } } const dmPolicy = account.config.dm?.policy ?? "pairing"; const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v)); const normalizedGroupUsers = groupUsers.map((v) => String(v)); const senderGroupPolicy = resolveSenderScopedGroupPolicy({ groupPolicy, groupAllowFrom: normalizedGroupUsers, }); const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config); const storeAllowFrom = !isGroup && dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeAuth) ? await pairing.readAllowFromStore().catch(() => []) : []; const access = resolveDmGroupAccessWithLists({ isGroup, dmPolicy, groupPolicy: senderGroupPolicy, allowFrom: configAllowFrom, groupAllowFrom: normalizedGroupUsers, storeAllowFrom, groupAllowFromFallbackToAllowFrom: false, isSenderAllowed: (allowFrom) => isSenderAllowed(senderId, senderEmail, allowFrom, allowNameMatching), }); const effectiveAllowFrom = access.effectiveAllowFrom; const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom; warnDeprecatedUsersEmailEntries(logVerbose, effectiveAllowFrom); const commandAllowFrom = isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom; const useAccessGroups = config.commands?.useAccessGroups !== false; const senderAllowedForCommands = isSenderAllowed( senderId, senderEmail, commandAllowFrom, allowNameMatching, ); const commandAuthorized = shouldComputeAuth ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, authorizers: [ { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands }, ], }) : undefined; if (isGroup) { const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true; const annotations = message.annotations ?? []; const mentionInfo = extractMentionInfo(annotations, account.config.botUser); const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg: config, surface: "googlechat", }); const 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(`drop group message (mention required, space=${spaceId})`); return { ok: false }; } } if (isGroup && access.decision !== "allow") { logVerbose( `drop group message (sender policy blocked, reason=${access.reason}, space=${spaceId})`, ); return { ok: false }; } if (!isGroup) { if (account.config.dm?.enabled === false) { logVerbose(`Blocked Google Chat DM from ${senderId} (dmPolicy=disabled)`); return { ok: false }; } if (access.decision !== "allow") { if (access.decision === "pairing") { await issuePairingChallenge({ channel: "googlechat", senderId, senderIdLine: `Your Google Chat user id: ${senderId}`, meta: { name: senderName || undefined, email: senderEmail }, upsertPairingRequest: pairing.upsertPairingRequest, onCreated: () => { logVerbose(`googlechat pairing request sender=${senderId}`); }, sendPairingReply: async (text) => { await sendGoogleChatMessage({ account, space: spaceId, text, }); statusSink?.({ lastOutboundAt: Date.now() }); }, onReplyError: (err) => { logVerbose(`pairing reply failed for ${senderId}: ${String(err)}`); }, }); } else { logVerbose(`Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`); } return { ok: false }; } } if ( isGroup && core.channel.commands.isControlCommandMessage(rawBody, config) && commandAuthorized !== true ) { logVerbose(`googlechat: drop control command from ${senderId}`); return { ok: false }; } return { ok: true, commandAuthorized, effectiveWasMentioned, groupSystemPrompt: groupEntry?.systemPrompt?.trim() || undefined, }; }