| 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 { |
| |
| 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; |
| } |
|
|
| |
| const withoutPrefix = normalized.replace(/^(googlechat|google-chat|gchat):/i, ""); |
| if (withoutPrefix.startsWith("users/")) { |
| return normalizeUserId(withoutPrefix) === normalizedSenderId; |
| } |
|
|
| |
| if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) { |
| return withoutPrefix === normalizedEmail; |
| } |
|
|
| return withoutPrefix.replace(/^users\//i, "") === normalizedSenderId; |
| }); |
| } |
|
|
| type GoogleChatGroupEntry = { |
| requireMention?: boolean; |
| allow?: boolean; |
| enabled?: boolean; |
| users?: Array<string | number>; |
| systemPrompt?: string; |
| }; |
|
|
| function resolveGroupConfig(params: { |
| groupId: string; |
| groupName?: string | null; |
| groups?: Record<string, GoogleChatGroupEntry>; |
| }) { |
| 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<string>(); |
|
|
| 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/<email>" is no longer treated as an email allowlist. Use raw email (alice@example.com) or immutable user id (users/<id>). 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, |
| }; |
| } |
|
|