Files
openclaw/extensions/googlechat/src/channel.adapters.ts
2026-04-08 01:36:39 +01:00

294 lines
9.3 KiB
TypeScript

import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
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 { OutboundMediaLoadOptions } from "openclaw/plugin-sdk/outbound-media";
import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import {
type ResolvedGoogleChatAccount,
chunkTextForOutbound,
fetchRemoteMedia,
isGoogleChatUserTarget,
loadOutboundMediaFromUrl,
missingTargetError,
normalizeGoogleChatTarget,
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
resolveGoogleChatAccount,
resolveGoogleChatOutboundSpace,
type OpenClawConfig,
} from "./channel.deps.runtime.js";
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(
() => import("./channel.runtime.js"),
"googleChatChannelRuntime",
);
export const formatAllowFromEntry = (entry: string) =>
normalizeLowercaseStringOrEmpty(
entry
.trim()
.replace(/^(googlechat|google-chat|gchat):/i, "")
.replace(/^user:/i, "")
.replace(/^users\//i, ""),
);
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) => formatAllowFromEntry(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,
},
};
export const googlechatPairingTextAdapter = {
idLabel: "googlechatUserId",
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: (entry: string) => formatAllowFromEntry(entry),
notify: async ({
cfg,
id,
message,
accountId,
}: {
cfg: OpenClawConfig;
id: string;
message: string;
accountId?: string | null;
}) => {
const account = resolveGoogleChatAccount({ cfg: 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,
sanitizeText: ({ text }: { text: string }) => sanitizeForPlainText(text),
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: 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,
});
return {
messageId: result?.messageName ?? "",
chatId: space,
};
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
accountId,
replyToId,
threadId,
}: {
cfg: OpenClawConfig;
to: string;
text?: string;
mediaUrl?: string;
mediaAccess?: OutboundMediaLoadOptions["mediaAccess"];
mediaLocalRoots?: OutboundMediaLoadOptions["mediaLocalRoots"];
mediaReadFile?: OutboundMediaLoadOptions["mediaReadFile"];
accountId?: string | null;
replyToId?: string | null;
threadId?: string | number | null;
}) => {
if (!mediaUrl) {
throw new Error("Google Chat mediaUrl is required.");
}
const account = resolveGoogleChatAccount({
cfg: cfg,
accountId,
});
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
const thread =
typeof threadId === "number" ? String(threadId) : (threadId ?? replyToId ?? undefined);
const maxBytes = resolveChannelMediaMaxBytes({
cfg: cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
(
cfg.channels?.googlechat as
| { accounts?: Record<string, { mediaMaxMb?: number }>; mediaMaxMb?: number }
| undefined
)?.accounts?.[accountId]?.mediaMaxMb ??
(cfg.channels?.googlechat as { mediaMaxMb?: number } | undefined)?.mediaMaxMb,
accountId,
});
const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
const loaded = /^https?:\/\//i.test(mediaUrl)
? await fetchRemoteMedia({
url: mediaUrl,
maxBytes: effectiveMaxBytes,
})
: await loadOutboundMediaFromUrl(mediaUrl, {
maxBytes: effectiveMaxBytes,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
});
const { sendGoogleChatMessage, uploadGoogleChatAttachment } =
await loadGoogleChatChannelRuntime();
const upload = await uploadGoogleChatAttachment({
account,
space,
filename: loaded.fileName ?? "attachment",
buffer: loaded.buffer,
contentType: loaded.contentType,
});
const result = await sendGoogleChatMessage({
account,
space,
text,
thread,
attachments: upload.attachmentUploadToken
? [
{
attachmentUploadToken: upload.attachmentUploadToken,
contentName: loaded.fileName,
},
]
: undefined,
});
return {
messageId: result?.messageName ?? "",
chatId: space,
};
},
},
};