Files
openclaw/extensions/googlechat/src/channel.adapters.ts
Josh Avant fbd330b7aa fix(channels): honor configured read target policies (#99905)
* fix(channels): enforce configured read targets

* test(channels): align policy checks with boundaries

* fix: bind channel reads to trusted turn context

* test: satisfy gateway lint

* fix: narrow message action channel imports

* fix(feishu): authorize message reads before provider access

* fix(slack): await reaction clear authorization

* fix(channels): align provider action contracts

* fix(matrix): read direct-room account data before sync

* fix(channels): reject unsupported attachment actions early

* fix: restore trusted operator conversation reads

* fix(matrix): authorize pin actions before provider reads

* fix: preserve trusted channel read workflows

* fix(discord): resolve current channel ids consistently

* fix(agents): preserve message action turn capability

* fix(plugins): enforce host-owned read provenance

* fix(channels): harden Teams and Discord read policy

* fix(channels): preserve exact-current action compatibility

* fix(imessage): authorize trusted current chat aliases

* fix(channels): preserve normalized current aliases

* fix(channels): preserve external current target aliases

* fix: reconcile channel policy with current main

* fix(discord): isolate DM read policy

* fix(channels): enforce provider read gates

* fix(gateway): await serialized message action identity tokens

* fix(ci): refresh channel protocol contracts
2026-07-10 22:29:37 -05:00

275 lines
8.8 KiB
TypeScript

// Googlechat plugin module implements channel.adapters behavior.
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
import type {
ChannelThreadingContext,
ChannelThreadingToolContext,
} from "openclaw/plugin-sdk/channel-contract";
import {
createMessageReceiptFromOutboundResults,
defineChannelMessageAdapter,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-outbound";
import { sanitizeForPlainText } from "openclaw/plugin-sdk/channel-outbound";
import {
composeAccountWarningCollectors,
createAllowlistProviderOpenWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import {
createChannelDirectoryAdapter,
listResolvedDirectoryGroupEntriesFromMapKeys,
listResolvedDirectoryUserEntriesFromAllowFrom,
} from "openclaw/plugin-sdk/directory-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
import { shouldSuppressGoogleChatManualExecApprovalFollowupPayload } from "./approval-card-actions.js";
import { formatGoogleChatAllowFromEntry } from "./channel-base.js";
import {
type ResolvedGoogleChatAccount,
chunkTextForOutbound,
isGoogleChatUserTarget,
missingTargetError,
normalizeGoogleChatTarget,
PAIRING_APPROVED_MESSAGE,
resolveGoogleChatAccount,
resolveGoogleChatOutboundSpace,
type OpenClawConfig,
} from "./channel.deps.runtime.js";
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(
() => import("./channel.runtime.js"),
"googleChatChannelRuntime",
);
function createGoogleChatSendReceipt(params: {
messageId?: string;
chatId: string;
kind: MessageReceiptPartKind;
}) {
const messageId = params.messageId?.trim();
return createMessageReceiptFromOutboundResults({
results: messageId
? [
{
channel: "googlechat",
messageId,
chatId: params.chatId,
conversationId: params.chatId,
},
]
: [],
threadId: params.chatId,
kind: params.kind,
});
}
const collectGoogleChatGroupPolicyWarnings =
createAllowlistProviderOpenWarningCollector<ResolvedGoogleChatAccount>({
providerConfigPresent: (cfg) => cfg.channels?.googlechat !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
buildOpenWarning: {
surface: "Google Chat spaces",
openBehavior: "allows any space to trigger (mention-gated)",
remediation:
'Set channels.googlechat.groupPolicy="allowlist" and configure channels.googlechat.groups',
},
});
const collectGoogleChatSecurityWarnings = composeAccountWarningCollectors<
ResolvedGoogleChatAccount,
{
cfg: OpenClawConfig;
account: ResolvedGoogleChatAccount;
}
>(
collectGoogleChatGroupPolicyWarnings,
(account) =>
account.config.dm?.policy === "open" &&
'- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".',
);
export const googlechatGroupsAdapter = {
resolveRequireMention: resolveGoogleChatGroupRequireMention,
};
export const googlechatDirectoryAdapter = createChannelDirectoryAdapter({
listPeers: async (params) =>
listResolvedDirectoryUserEntriesFromAllowFrom<ResolvedGoogleChatAccount>({
...params,
resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount),
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
normalizeId: (entry) => normalizeGoogleChatTarget(entry) ?? entry,
}),
listGroups: async (params) =>
listResolvedDirectoryGroupEntriesFromMapKeys<ResolvedGoogleChatAccount>({
...params,
resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount),
resolveGroups: (account) => account.config.groups,
}),
});
export const googlechatSecurityAdapter = {
dm: {
channelKey: "googlechat",
resolvePolicy: (account: ResolvedGoogleChatAccount) => account.config.dm?.policy,
resolveAllowFrom: (account: ResolvedGoogleChatAccount) => account.config.dm?.allowFrom,
allowFromPathSuffix: "dm.",
normalizeEntry: (raw: string) => formatGoogleChatAllowFromEntry(raw),
},
collectWarnings: collectGoogleChatSecurityWarnings,
};
export const googlechatThreadingAdapter = {
scopedAccountReplyToMode: {
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
resolveGoogleChatAccount({ cfg, accountId }),
resolveReplyToMode: (account: ResolvedGoogleChatAccount, _chatType?: string | null) =>
account.config.replyToMode,
fallback: "off" as const,
},
buildToolContext: ({
cfg,
accountId,
context,
hasRepliedRef,
}: {
cfg: OpenClawConfig;
accountId?: string | null;
context: ChannelThreadingContext;
hasRepliedRef?: { value: boolean };
}): ChannelThreadingToolContext => {
const currentChannelId = normalizeGoogleChatTarget(context.To);
const replyToId =
normalizeOptionalString(context.ReplyToIdFull) ?? normalizeOptionalString(context.ReplyToId);
return {
currentChannelId,
currentMessageId: replyToId,
currentThreadTs: replyToId,
replyToMode: resolveGoogleChatAccount({ cfg, accountId }).config.replyToMode,
hasRepliedRef,
};
},
};
export const googlechatPairingTextAdapter = {
idLabel: "googlechatUserId",
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: (entry: string) => formatGoogleChatAllowFromEntry(entry),
notify: async ({
cfg,
id,
message,
accountId,
}: {
cfg: OpenClawConfig;
id: string;
message: string;
accountId?: string | null;
}) => {
const account = resolveGoogleChatAccount({ cfg, accountId });
if (account.credentialSource === "none") {
return;
}
const user = normalizeGoogleChatTarget(id) ?? id;
const target = isGoogleChatUserTarget(user) ? user : `users/${user}`;
const space = await resolveGoogleChatOutboundSpace({ account, target });
const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime();
await sendGoogleChatMessage({
account,
space,
text: message,
});
},
};
export const googlechatOutboundAdapter = {
base: {
deliveryMode: "direct" as const,
chunker: chunkTextForOutbound,
chunkerMode: "markdown" as const,
textChunkLimit: 4000,
// Google Chat's plain-text pass does not remove assistant scaffolding.
// Run the canonical delivery sanitizer first so internal tool traces are
// dropped before channel formatting.
sanitizeText: ({ text }: { text: string }) =>
sanitizeForPlainText(sanitizeAssistantVisibleText(text)),
normalizePayload: ({ payload }: { payload: ReplyPayload }) =>
shouldSuppressGoogleChatManualExecApprovalFollowupPayload(payload) ? null : payload,
resolveTarget: ({ to }: { to?: string }) => {
const trimmed = normalizeOptionalString(to) ?? "";
if (trimmed) {
const normalized = normalizeGoogleChatTarget(trimmed);
if (!normalized) {
return {
ok: false as const,
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
};
}
return { ok: true as const, to: normalized };
}
return {
ok: false as const,
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
};
},
},
attachedResults: {
channel: "googlechat" as const,
sendText: async ({
cfg,
to,
text,
accountId,
replyToId,
threadId,
}: {
cfg: OpenClawConfig;
to: string;
text: string;
accountId?: string | null;
replyToId?: string | null;
threadId?: string | number | null;
}) => {
const account = resolveGoogleChatAccount({
cfg,
accountId,
});
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
const thread =
typeof threadId === "number" ? String(threadId) : (threadId ?? replyToId ?? undefined);
const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime();
const result = await sendGoogleChatMessage({
account,
space,
text,
thread,
});
const messageId = result?.messageName ?? "";
return {
messageId,
chatId: space,
receipt: createGoogleChatSendReceipt({ messageId, chatId: space, kind: "text" }),
};
},
},
};
export const googlechatMessageAdapter = defineChannelMessageAdapter({
id: "googlechat",
durableFinal: {
capabilities: {
text: true,
thread: true,
messageSendingHooks: true,
},
},
send: {
text: googlechatOutboundAdapter.attachedResults.sendText,
},
});