mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-15 12:00:43 +00:00
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
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:<id>` but treat `users/...` as an *ID* only (deprecated `users/<email>`).
|
|
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<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,
|
|
};
|
|
}
|