From 27f655ed113637b07d2dabf6d5b837aca25187da Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Mar 2026 16:36:09 +0000 Subject: [PATCH] refactor: deduplicate channel runtime helpers --- extensions/bluebubbles/src/channel.ts | 46 ++-- extensions/discord/src/channel.ts | 220 +++++++--------- extensions/discord/src/directory-config.ts | 53 ++-- extensions/feishu/src/channel.ts | 109 +++++--- .../googlechat/src/channel.directory.test.ts | 58 ++++ extensions/googlechat/src/channel.ts | 113 ++++---- extensions/imessage/src/channel.ts | 32 +-- extensions/imessage/src/shared.ts | 15 +- extensions/irc/src/channel.ts | 174 ++++++------ extensions/line/src/channel.ts | 52 ++-- extensions/matrix/src/channel.ts | 226 +++++++--------- extensions/mattermost/src/channel.ts | 42 +-- extensions/msteams/src/channel.ts | 161 ++++++------ extensions/nextcloud-talk/src/channel.ts | 69 +++-- extensions/signal/src/channel.ts | 50 ++-- extensions/signal/src/shared.ts | 15 +- extensions/slack/src/channel.ts | 190 ++++++-------- extensions/slack/src/directory-config.ts | 49 ++-- extensions/synology-chat/src/channel.test.ts | 14 +- extensions/synology-chat/src/channel.ts | 81 +++--- extensions/telegram/src/channel.ts | 148 +++++------ extensions/telegram/src/directory-config.ts | 43 ++- extensions/tlon/src/channel.ts | 19 +- .../whatsapp/src/channel.directory.test.ts | 62 +++++ extensions/whatsapp/src/channel.ts | 31 +-- extensions/whatsapp/src/directory-config.ts | 22 +- extensions/whatsapp/src/shared.ts | 55 ++-- extensions/zalo/src/channel.ts | 94 +++---- extensions/zalouser/src/channel.ts | 15 +- .../plugins/directory-adapters.test.ts | 35 +++ src/channels/plugins/directory-adapters.ts | 28 ++ .../plugins/directory-config-helpers.test.ts | 97 +++++++ .../plugins/directory-config-helpers.ts | 90 +++++++ .../plugins/group-policy-warnings.test.ts | 240 +++++++++++++++++ src/channels/plugins/group-policy-warnings.ts | 171 ++++++++++++ src/channels/plugins/pairing-adapters.test.ts | 37 +++ src/channels/plugins/pairing-adapters.ts | 34 +++ .../plugins/runtime-forwarders.test.ts | 54 ++++ src/channels/plugins/runtime-forwarders.ts | 117 +++++++++ src/channels/plugins/target-resolvers.test.ts | 40 +++ src/channels/plugins/target-resolvers.ts | 30 +++ src/plugin-sdk/allowlist-config-edit.test.ts | 247 ++++++++++++++++++ src/plugin-sdk/allowlist-config-edit.ts | 214 ++++++++++++++- src/plugin-sdk/channel-policy.ts | 10 + src/plugin-sdk/channel-runtime.ts | 4 + src/plugin-sdk/directory-runtime.ts | 5 + src/plugin-sdk/subpaths.test.ts | 35 +++ 47 files changed, 2595 insertions(+), 1151 deletions(-) create mode 100644 extensions/googlechat/src/channel.directory.test.ts create mode 100644 extensions/whatsapp/src/channel.directory.test.ts create mode 100644 src/channels/plugins/directory-adapters.test.ts create mode 100644 src/channels/plugins/directory-adapters.ts create mode 100644 src/channels/plugins/pairing-adapters.test.ts create mode 100644 src/channels/plugins/pairing-adapters.ts create mode 100644 src/channels/plugins/runtime-forwarders.test.ts create mode 100644 src/channels/plugins/runtime-forwarders.ts create mode 100644 src/channels/plugins/target-resolvers.test.ts create mode 100644 src/channels/plugins/target-resolvers.ts create mode 100644 src/plugin-sdk/allowlist-config-edit.test.ts diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 33249fcfa9e..b13d21f71fd 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -4,7 +4,14 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; -import { collectOpenGroupPolicyRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; +import { + createOpenGroupPolicyRestrictSendersWarningCollector, + projectWarningCollector, +} from "openclaw/plugin-sdk/channel-policy"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { listBlueBubblesAccountIds, @@ -68,6 +75,17 @@ const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")), }); +const collectBlueBubblesSecurityWarnings = + createOpenGroupPolicyRestrictSendersWarningCollector({ + resolveGroupPolicy: (account) => account.config.groupPolicy, + defaultGroupPolicy: "allowlist", + surface: "BlueBubbles groups", + openScope: "any member", + groupPolicyPath: "channels.bluebubbles.groupPolicy", + groupAllowFromPath: "channels.bluebubbles.groupAllowFrom", + mentionGated: false, + }); + const meta = { id: "bluebubbles", label: "BlueBubbles", @@ -123,17 +141,10 @@ export const bluebubblesPlugin: ChannelPlugin = { actions: bluebubblesMessageActions, security: { resolveDmPolicy: resolveBlueBubblesDmPolicy, - collectWarnings: ({ account }) => { - const groupPolicy = account.config.groupPolicy ?? "allowlist"; - return collectOpenGroupPolicyRestrictSendersWarnings({ - groupPolicy, - surface: "BlueBubbles groups", - openScope: "any member", - groupPolicyPath: "channels.bluebubbles.groupPolicy", - groupAllowFromPath: "channels.bluebubbles.groupAllowFrom", - mentionGated: false, - }); - }, + collectWarnings: projectWarningCollector( + ({ account }: { account: ResolvedBlueBubblesAccount }) => account, + collectBlueBubblesSecurityWarnings, + ), }, messaging: { normalizeTarget: normalizeBlueBubblesMessagingTarget, @@ -226,17 +237,18 @@ export const bluebubblesPlugin: ChannelPlugin = { }, }, setup: blueBubblesSetupAdapter, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "bluebubblesSenderId", - normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), - notifyApproval: async ({ cfg, id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^bluebubbles:/i, normalizeBlueBubblesHandle), + notify: async ({ cfg, id, message }) => { await ( await loadBlueBubblesChannelRuntime() - ).sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, { + ).sendMessageBlueBubbles(id, message, { cfg: cfg, }); }, - }, + }), outbound: { deliveryMode: "direct", textChunkLimit: 4000, diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 1224fc7b37a..24a8577af3a 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,15 +1,20 @@ import { Separator, TextDisplay } from "@buape/carbon"; import { - buildAccountScopedAllowlistConfigEditor, - resolveLegacyDmAllowlistConfigPaths, + buildLegacyDmAccountAllowlistAdapter, + createAccountScopedAllowlistNameResolver, + createNestedAllowlistOverrideResolver, } from "openclaw/plugin-sdk/allowlist-config-edit"; import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { - collectOpenGroupPolicyConfiguredRouteWarnings, - collectOpenProviderGroupPolicyWarnings, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; -import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; + createChannelDirectoryAdapter, + createPairingPrefixStripper, + createRuntimeDirectoryLiveAdapter, + createTextPairingAdapter, + normalizeMessageChannel, + resolveOutboundSendDep, + resolveTargetsWithOptionalToken, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { @@ -131,42 +136,40 @@ function hasDiscordExecApprovalDmRoute(cfg: OpenClawConfig): boolean { }); } -function readDiscordAllowlistConfig(account: ResolvedDiscordAccount) { - const groupOverrides: Array<{ label: string; entries: string[] }> = []; - for (const [guildKey, guildCfg] of Object.entries(account.config.guilds ?? {})) { - const entries = (guildCfg?.users ?? []).map(String).filter(Boolean); - if (entries.length > 0) { - groupOverrides.push({ label: `guild ${guildKey}`, entries }); - } - for (const [channelKey, channelCfg] of Object.entries(guildCfg?.channels ?? {})) { - const channelEntries = (channelCfg?.users ?? []).map(String).filter(Boolean); - if (channelEntries.length > 0) { - groupOverrides.push({ - label: `guild ${guildKey} / channel ${channelKey}`, - entries: channelEntries, - }); - } - } - } - return { - dmAllowFrom: (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String), - groupPolicy: account.config.groupPolicy, - groupOverrides, - }; -} +const resolveDiscordAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({ + resolveRecord: (account: ResolvedDiscordAccount) => account.config.guilds, + outerLabel: (guildKey) => `guild ${guildKey}`, + resolveOuterEntries: (guildCfg) => guildCfg?.users, + resolveChildren: (guildCfg) => guildCfg?.channels, + innerLabel: (guildKey, channelKey) => `guild ${guildKey} / channel ${channelKey}`, + resolveInnerEntries: (channelCfg) => channelCfg?.users, +}); -async function resolveDiscordAllowlistNames(params: { - cfg: Parameters[0]["cfg"]; - accountId?: string | null; - entries: string[]; -}) { - const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); - const token = account.token?.trim(); - if (!token) { - return []; - } - return await resolveDiscordUserAllowlist({ token, entries: params.entries }); -} +const resolveDiscordAllowlistNames = createAccountScopedAllowlistNameResolver({ + resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), + resolveToken: (account: ResolvedDiscordAccount) => account.token, + resolveNames: ({ token, entries }) => resolveDiscordUserAllowlist({ token, entries }), +}); + +const collectDiscordSecurityWarnings = + createOpenProviderConfiguredRouteWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.discord !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Object.keys(account.config.guilds ?? {}).length > 0, + configureRouteAllowlist: { + surface: "Discord guilds", + openScope: "any channel not explicitly denied", + groupPolicyPath: "channels.discord.groupPolicy", + routeAllowlistPath: "channels.discord.guilds..channels", + }, + missingRouteAllowlist: { + surface: "Discord guilds", + openBehavior: "with no guild/channel allowlist; any channel can trigger (mention-gated)", + remediation: + 'Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds..channels', + }, + }); function normalizeDiscordAcpConversationId(conversationId: string) { const normalized = conversationId.trim(); @@ -288,60 +291,29 @@ export const discordPlugin: ChannelPlugin = { ...createDiscordPluginBase({ setup: discordSetupAdapter, }), - pairing: { + pairing: createTextPairingAdapter({ idLabel: "discordUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""), - notifyApproval: async ({ id }) => { - await getDiscordRuntime().channel.discord.sendMessageDiscord( - `user:${id}`, - PAIRING_APPROVED_MESSAGE, - ); + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(discord|user):/i), + notify: async ({ id, message }) => { + await getDiscordRuntime().channel.discord.sendMessageDiscord(`user:${id}`, message); }, - }, + }), allowlist: { - supportsScope: ({ scope }) => scope === "dm", - readConfig: ({ cfg, accountId }) => - readDiscordAllowlistConfig(resolveDiscordAccount({ cfg, accountId })), - resolveNames: async ({ cfg, accountId, entries }) => - await resolveDiscordAllowlistNames({ cfg, accountId, entries }), - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ + ...buildLegacyDmAccountAllowlistAdapter({ channelId: "discord", + resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), normalize: ({ cfg, accountId, values }) => discordConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolvePaths: resolveLegacyDmAllowlistConfigPaths, + resolveDmAllowFrom: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveGroupOverrides: resolveDiscordAllowlistGroupOverrides, }), + resolveNames: resolveDiscordAllowlistNames, }, security: { resolveDmPolicy: resolveDiscordDmPolicy, - collectWarnings: ({ account, cfg }) => { - const guildEntries = account.config.guilds ?? {}; - const guildsConfigured = Object.keys(guildEntries).length > 0; - const channelAllowlistConfigured = guildsConfigured; - - return collectOpenProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.discord !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyConfiguredRouteWarnings({ - groupPolicy, - routeAllowlistConfigured: channelAllowlistConfigured, - configureRouteAllowlist: { - surface: "Discord guilds", - openScope: "any channel not explicitly denied", - groupPolicyPath: "channels.discord.groupPolicy", - routeAllowlistPath: "channels.discord.guilds..channels", - }, - missingRouteAllowlist: { - surface: "Discord guilds", - openBehavior: - "with no guild/channel allowlist; any channel can trigger (mention-gated)", - remediation: - 'Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds..channels', - }, - }), - }); - }, + collectWarnings: collectDiscordSecurityWarnings, }, groups: { resolveRequireMention: resolveDiscordGroupRequireMention, @@ -387,53 +359,57 @@ export const discordPlugin: ChannelPlugin = { (normalizeMessageChannel(target.channel) ?? target.channel) === "discord" && isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId }), }, - directory: { - self: async () => null, + directory: createChannelDirectoryAdapter({ listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params), listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params), - listPeersLive: async (params) => - getDiscordRuntime().channel.discord.listDirectoryPeersLive(params), - listGroupsLive: async (params) => - getDiscordRuntime().channel.discord.listDirectoryGroupsLive(params), - }, + ...createRuntimeDirectoryLiveAdapter({ + getRuntime: () => getDiscordRuntime().channel.discord, + listPeersLive: (runtime) => runtime.listDirectoryPeersLive, + listGroupsLive: (runtime) => runtime.listDirectoryGroupsLive, + }), + }), resolver: { resolveTargets: async ({ cfg, accountId, inputs, kind }) => { const account = resolveDiscordAccount({ cfg, accountId }); - const token = account.token?.trim(); - if (!token) { - return inputs.map((input) => ({ - input, - resolved: false, - note: "missing Discord token", - })); - } if (kind === "group") { - const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({ - token, - entries: inputs, + return resolveTargetsWithOptionalToken({ + token: account.token, + inputs, + missingTokenNote: "missing Discord token", + resolveWithToken: ({ token, inputs }) => + getDiscordRuntime().channel.discord.resolveChannelAllowlist({ + token, + entries: inputs, + }), + mapResolved: (entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.channelId ?? entry.guildId, + name: + entry.channelName ?? + entry.guildName ?? + (entry.guildId && !entry.channelId ? entry.guildId : undefined), + note: entry.note, + }), }); - return resolved.map((entry) => ({ + } + return resolveTargetsWithOptionalToken({ + token: account.token, + inputs, + missingTokenNote: "missing Discord token", + resolveWithToken: ({ token, inputs }) => + getDiscordRuntime().channel.discord.resolveUserAllowlist({ + token, + entries: inputs, + }), + mapResolved: (entry) => ({ input: entry.input, resolved: entry.resolved, - id: entry.channelId ?? entry.guildId, - name: - entry.channelName ?? - entry.guildName ?? - (entry.guildId && !entry.channelId ? entry.guildId : undefined), + id: entry.id, + name: entry.name, note: entry.note, - })); - } - const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({ - token, - entries: inputs, + }), }); - return resolved.map((entry) => ({ - input: entry.input, - resolved: entry.resolved, - id: entry.id, - name: entry.name, - note: entry.note, - })); }, }, actions: discordMessageActions, diff --git a/extensions/discord/src/directory-config.ts b/extensions/discord/src/directory-config.ts index 69b39d4f9a5..19ec9ce18b5 100644 --- a/extensions/discord/src/directory-config.ts +++ b/extensions/discord/src/directory-config.ts @@ -1,54 +1,43 @@ import { - applyDirectoryQueryAndLimit, - collectNormalizedDirectoryIds, - toDirectoryEntries, + listInspectedDirectoryEntriesFromSources, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; import { inspectDiscordAccount, type InspectedDiscordAccount } from "../api.js"; export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account: InspectedDiscordAccount = inspectDiscordAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - - const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? []; - const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [ - ...(guild.users ?? []), - ...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []), - ]); - const ids = collectNormalizedDirectoryIds({ - sources: [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers], + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "user", + inspectAccount: (cfg, accountId) => + inspectDiscordAccount({ cfg, accountId }) as InspectedDiscordAccount | null, + resolveSources: (account) => { + const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? []; + const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [ + ...(guild.users ?? []), + ...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []), + ]); + return [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers]; + }, normalizeId: (raw) => { const mention = raw.match(/^<@!?(\d+)>$/); const cleaned = (mention?.[1] ?? raw).replace(/^(discord|user):/i, "").trim(); return /^\d+$/.test(cleaned) ? `user:${cleaned}` : null; }, }); - return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params)); } export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account: InspectedDiscordAccount = inspectDiscordAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - - const ids = collectNormalizedDirectoryIds({ - sources: Object.values(account.config.guilds ?? {}).map((guild) => - Object.keys(guild.channels ?? {}), - ), + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "group", + inspectAccount: (cfg, accountId) => + inspectDiscordAccount({ cfg, accountId }) as InspectedDiscordAccount | null, + resolveSources: (account) => + Object.values(account.config.guilds ?? {}).map((guild) => Object.keys(guild.channels ?? {})), normalizeId: (raw) => { const mention = raw.match(/^<#(\d+)>$/); const cleaned = (mention?.[1] ?? raw).replace(/^(discord|channel|group):/i, "").trim(); return /^\d+$/.test(cleaned) ? `channel:${cleaned}` : null; }, }); - return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params)); } diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 0aa071e7abd..97fd5dd068d 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,7 +1,17 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; +import { + createAllowlistProviderGroupPolicyWarningCollector, + projectWarningCollector, +} from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createMessageToolCardSchema, + createPairingPrefixStripper, + createRuntimeDirectoryLiveAdapter, + createRuntimeOutboundDelegates, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery, @@ -53,6 +63,24 @@ const loadFeishuChannelRuntime = createLazyRuntimeNamedExport( "feishuChannelRuntime", ); +const collectFeishuSecurityWarnings = createAllowlistProviderGroupPolicyWarningCollector<{ + cfg: ClawdbotConfig; + accountId?: string | null; +}>({ + providerConfigPresent: (cfg) => cfg.channels?.feishu !== undefined, + resolveGroupPolicy: ({ cfg, accountId }) => + resolveFeishuAccount({ cfg, accountId }).config?.groupPolicy, + collect: ({ cfg, accountId, groupPolicy }) => { + if (groupPolicy !== "open") { + return []; + } + const account = resolveFeishuAccount({ cfg, accountId }); + return [ + `- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`, + ]; + }, +}); + function describeFeishuMessageTool({ cfg, }: Parameters< @@ -355,18 +383,19 @@ export const feishuPlugin: ChannelPlugin = { meta: { ...meta, }, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "feishuUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""), - notifyApproval: async ({ cfg, id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(feishu|user|open_id):/i), + notify: async ({ cfg, id, message }) => { const { sendMessageFeishu } = await loadFeishuChannelRuntime(); await sendMessageFeishu({ cfg, to: id, - text: PAIRING_APPROVED_MESSAGE, + text: message, }); }, - }, + }), capabilities: { chatTypes: ["direct", "channel"], polls: false, @@ -839,19 +868,13 @@ export const feishuPlugin: ChannelPlugin = { }, }, security: { - collectWarnings: ({ cfg, accountId }) => { - const account = resolveFeishuAccount({ cfg, accountId }); - const feishuCfg = account.config; - return collectAllowlistProviderRestrictSendersWarnings({ + collectWarnings: projectWarningCollector( + ({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string | null }) => ({ cfg, - providerConfigPresent: cfg.channels?.feishu !== undefined, - configuredGroupPolicy: feishuCfg?.groupPolicy, - surface: `Feishu[${account.accountId}] groups`, - openScope: "any member", - groupPolicyPath: "channels.feishu.groupPolicy", - groupAllowFromPath: "channels.feishu.groupAllowFrom", - }); - }, + accountId, + }), + collectFeishuSecurityWarnings, + ), }, bindings: { compileConfiguredBinding: ({ conversationId }) => @@ -873,8 +896,7 @@ export const feishuPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, + directory: createChannelDirectoryAdapter({ listPeers: async ({ cfg, query, limit, accountId }) => listFeishuDirectoryPeers({ cfg, @@ -889,29 +911,38 @@ export const feishuPlugin: ChannelPlugin = { limit: limit ?? undefined, accountId: accountId ?? undefined, }), - listPeersLive: async ({ cfg, query, limit, accountId }) => - (await loadFeishuChannelRuntime()).listFeishuDirectoryPeersLive({ - cfg, - query: query ?? undefined, - limit: limit ?? undefined, - accountId: accountId ?? undefined, - }), - listGroupsLive: async ({ cfg, query, limit, accountId }) => - (await loadFeishuChannelRuntime()).listFeishuDirectoryGroupsLive({ - cfg, - query: query ?? undefined, - limit: limit ?? undefined, - accountId: accountId ?? undefined, - }), - }, + ...createRuntimeDirectoryLiveAdapter({ + getRuntime: loadFeishuChannelRuntime, + listPeersLive: + (runtime) => + async ({ cfg, query, limit, accountId }) => + await runtime.listFeishuDirectoryPeersLive({ + cfg, + query: query ?? undefined, + limit: limit ?? undefined, + accountId: accountId ?? undefined, + }), + listGroupsLive: + (runtime) => + async ({ cfg, query, limit, accountId }) => + await runtime.listFeishuDirectoryGroupsLive({ + cfg, + query: query ?? undefined, + limit: limit ?? undefined, + accountId: accountId ?? undefined, + }), + }), + }), outbound: { deliveryMode: "direct", chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async (params) => (await loadFeishuChannelRuntime()).feishuOutbound.sendText!(params), - sendMedia: async (params) => - (await loadFeishuChannelRuntime()).feishuOutbound.sendMedia!(params), + ...createRuntimeOutboundDelegates({ + getRuntime: loadFeishuChannelRuntime, + sendText: { resolve: (runtime) => runtime.feishuOutbound.sendText }, + sendMedia: { resolve: (runtime) => runtime.feishuOutbound.sendMedia }, + }), }, status: { defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }), diff --git a/extensions/googlechat/src/channel.directory.test.ts b/extensions/googlechat/src/channel.directory.test.ts new file mode 100644 index 00000000000..7dbf68a0934 --- /dev/null +++ b/extensions/googlechat/src/channel.directory.test.ts @@ -0,0 +1,58 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/googlechat"; +import { describe, expect, it } from "vitest"; +import { + createDirectoryTestRuntime, + expectDirectorySurface, +} from "../../../test/helpers/extensions/directory.ts"; +import { googlechatPlugin } from "./channel.js"; + +describe("googlechat directory", () => { + const runtimeEnv = createDirectoryTestRuntime() as never; + + it("lists peers and groups from config", async () => { + const cfg = { + channels: { + googlechat: { + serviceAccount: { client_email: "bot@example.com" }, + dm: { allowFrom: ["users/alice", "googlechat:bob"] }, + groups: { + "spaces/AAA": {}, + "spaces/BBB": {}, + }, + }, + }, + } as unknown as OpenClawConfig; + + const directory = expectDirectorySurface(googlechatPlugin.directory); + + await expect( + directory.listPeers({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "user", id: "users/alice" }, + { kind: "user", id: "bob" }, + ]), + ); + + await expect( + directory.listGroups({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "group", id: "spaces/AAA" }, + { kind: "group", id: "spaces/BBB" }, + ]), + ); + }); +}); diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 7cc86e81cda..856891cfb48 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -4,9 +4,19 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildOpenGroupPolicyConfigureRouteAllowlistWarning, - collectAllowlistProviderGroupPolicyWarnings, + composeWarningCollectors, + createAllowlistProviderGroupPolicyWarningCollector, + createConditionalWarningCollector, + createAllowlistProviderOpenWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; +import { + listResolvedDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryUserEntriesFromAllowFrom, +} from "openclaw/plugin-sdk/directory-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { @@ -15,8 +25,6 @@ import { DEFAULT_ACCOUNT_ID, createAccountStatusSink, getChatChannelMeta, - listDirectoryGroupEntriesFromMapKeys, - listDirectoryUserEntriesFromAllowFrom, missingTargetError, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, @@ -103,15 +111,40 @@ const googlechatActions: ChannelMessageActionAdapter = { }, }; +const collectGoogleChatGroupPolicyWarnings = + createAllowlistProviderOpenWarningCollector({ + 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 = composeWarningCollectors<{ + cfg: OpenClawConfig; + account: ResolvedGoogleChatAccount; +}>( + collectGoogleChatGroupPolicyWarnings, + createConditionalWarningCollector( + ({ account }) => + account.config.dm?.policy === "open" && + '- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".', + ), +); + export const googlechatPlugin: ChannelPlugin = { id: "googlechat", meta: { ...meta }, setup: googlechatSetupAdapter, setupWizard: googlechatSetupWizard, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "googlechatUserId", + message: PAIRING_APPROVED_MESSAGE, normalizeAllowEntry: (entry) => formatAllowFromEntry(entry), - notifyApproval: async ({ cfg, id }) => { + notify: async ({ cfg, id, message }) => { const account = resolveGoogleChatAccount({ cfg: cfg }); if (account.credentialSource === "none") { return; @@ -123,10 +156,10 @@ export const googlechatPlugin: ChannelPlugin = { await sendGoogleChatMessage({ account, space, - text: PAIRING_APPROVED_MESSAGE, + text: message, }); }, - }, + }), capabilities: { chatTypes: ["direct", "group", "thread"], reactions: true, @@ -153,30 +186,7 @@ export const googlechatPlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveGoogleChatDmPolicy, - collectWarnings: ({ account, cfg }) => { - const warnings = collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.googlechat !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - groupPolicy === "open" - ? [ - buildOpenGroupPolicyConfigureRouteAllowlistWarning({ - surface: "Google Chat spaces", - openScope: "any space", - groupPolicyPath: "channels.googlechat.groupPolicy", - routeAllowlistPath: "channels.googlechat.groups", - }), - ] - : [], - }); - if (account.config.dm?.policy === "open") { - warnings.push( - `- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".`, - ); - } - return warnings; - }, + collectWarnings: collectGoogleChatSecurityWarnings, }, groups: { resolveRequireMention: resolveGoogleChatGroupRequireMention, @@ -194,32 +204,21 @@ export const googlechatPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, - listPeers: async ({ cfg, accountId, query, limit }) => { - const account = resolveGoogleChatAccount({ - cfg: cfg, - accountId, - }); - return listDirectoryUserEntriesFromAllowFrom({ - allowFrom: account.config.dm?.allowFrom, - query, - limit, + directory: createChannelDirectoryAdapter({ + listPeers: async (params) => + listResolvedDirectoryUserEntriesFromAllowFrom({ + ...params, + resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }), + resolveAllowFrom: (account) => account.config.dm?.allowFrom, normalizeId: (entry) => normalizeGoogleChatTarget(entry) ?? entry, - }); - }, - listGroups: async ({ cfg, accountId, query, limit }) => { - const account = resolveGoogleChatAccount({ - cfg: cfg, - accountId, - }); - return listDirectoryGroupEntriesFromMapKeys({ - groups: account.config.groups, - query, - limit, - }); - }, - }, + }), + listGroups: async (params) => + listResolvedDirectoryGroupEntriesFromMapKeys({ + ...params, + resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }), + resolveGroups: (account) => account.config.groups, + }), + }), resolver: { resolveTargets: async ({ inputs, kind }) => { const resolved = inputs.map((input) => { diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 27a26a9db88..bd7df04e249 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,4 +1,4 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; @@ -21,6 +21,7 @@ import { imessageSetupAdapter } from "./setup-core.js"; import { collectIMessageSecurityWarnings, createIMessagePluginBase, + imessageConfigAdapter, imessageResolveDmPolicy, imessageSetupWizard, } from "./shared.js"; @@ -113,26 +114,15 @@ export const imessagePlugin: ChannelPlugin = { notifyApproval: async ({ id }) => await (await loadIMessageChannelRuntime()).notifyIMessageApproval(id), }, - allowlist: { - supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", - readConfig: ({ cfg, accountId }) => { - const account = resolveIMessageAccount({ cfg, accountId }); - return { - dmAllowFrom: (account.config.allowFrom ?? []).map(String), - groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String), - dmPolicy: account.config.dmPolicy, - groupPolicy: account.config.groupPolicy, - }; - }, - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ - channelId: "imessage", - normalize: ({ values }) => formatTrimmedAllowFromEntries(values), - resolvePaths: (scope) => ({ - readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], - writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], - }), - }), - }, + allowlist: buildDmGroupAccountAllowlistAdapter({ + channelId: "imessage", + resolveAccount: ({ cfg, accountId }) => resolveIMessageAccount({ cfg, accountId }), + normalize: ({ values }) => formatTrimmedAllowFromEntries(values), + resolveDmAllowFrom: (account) => account.config.allowFrom, + resolveGroupAllowFrom: (account) => account.config.groupAllowFrom, + resolveDmPolicy: (account) => account.config.dmPolicy, + resolveGroupPolicy: (account) => account.config.groupPolicy, + }), security: { resolveDmPolicy: imessageResolveDmPolicy, collectWarnings: collectIMessageSecurityWarnings, diff --git a/extensions/imessage/src/shared.ts b/extensions/imessage/src/shared.ts index cf3e7b173cf..41275715c36 100644 --- a/extensions/imessage/src/shared.ts +++ b/extensions/imessage/src/shared.ts @@ -1,9 +1,9 @@ import { - collectAllowlistProviderRestrictSendersWarnings, createScopedChannelConfigAdapter, createScopedDmSecurityResolver, formatTrimmedAllowFromEntries, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { buildChannelConfigSchema, @@ -47,21 +47,16 @@ export const imessageResolveDmPolicy = createScopedDmSecurityResolver[0]["cfg"]; -}) { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg: params.cfg, - providerConfigPresent: params.cfg.channels?.imessage !== undefined, - configuredGroupPolicy: params.account.config.groupPolicy, +export const collectIMessageSecurityWarnings = + createAllowlistProviderRestrictSendersWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.imessage !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, surface: "iMessage groups", openScope: "any member", groupPolicyPath: "channels.imessage.groupPolicy", groupAllowFromPath: "channels.imessage.groupAllowFrom", mentionGated: false, }); -} export function createIMessagePluginBase(params: { setupWizard?: NonNullable["setupWizard"]>; diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index a0f6c9a5bc8..216ce997d16 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -4,9 +4,15 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildOpenGroupPolicyWarning, - collectAllowlistProviderGroupPolicyWarnings, + composeWarningCollectors, + createAllowlistProviderOpenWarningCollector, + createConditionalWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createTextPairingAdapter, + listResolvedDirectoryEntriesFromSources, +} from "openclaw/plugin-sdk/channel-runtime"; import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; import { listIrcAccountIds, @@ -88,6 +94,36 @@ const resolveIrcDmPolicy = createScopedDmSecurityResolver({ normalizeEntry: (raw) => normalizeIrcAllowEntry(raw), }); +const collectIrcGroupPolicyWarnings = + createAllowlistProviderOpenWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.irc !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + buildOpenWarning: { + surface: "IRC channels", + openBehavior: "allows all channels and senders (mention-gated)", + remediation: 'Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups', + }, + }); + +const collectIrcSecurityWarnings = composeWarningCollectors<{ + account: ResolvedIrcAccount; + cfg: CoreConfig; +}>( + collectIrcGroupPolicyWarnings, + createConditionalWarningCollector( + ({ account }) => + !account.config.tls && + "- IRC TLS is disabled (channels.irc.tls=false); traffic and credentials are plaintext.", + ({ account }) => + account.config.nickserv?.register && + '- IRC NickServ registration is enabled (channels.irc.nickserv.register=true); this sends "REGISTER" on every connect. Disable after first successful registration.', + ({ account }) => + account.config.nickserv?.register && + !account.config.nickserv.password?.trim() && + "- IRC NickServ registration is enabled but no NickServ password is resolved; set channels.irc.nickserv.password, channels.irc.nickserv.passwordFile, or IRC_NICKSERV_PASSWORD.", + ), +); + export const ircPlugin: ChannelPlugin = { id: "irc", meta: { @@ -96,17 +132,18 @@ export const ircPlugin: ChannelPlugin = { }, setup: ircSetupAdapter, setupWizard: ircSetupWizard, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "ircUser", + message: PAIRING_APPROVED_MESSAGE, normalizeAllowEntry: (entry) => normalizeIrcAllowEntry(entry), - notifyApproval: async ({ id }) => { + notify: async ({ id, message }) => { const target = normalizePairingTarget(id); if (!target) { throw new Error(`invalid IRC pairing id: ${id}`); } - await sendMessageIrc(target, PAIRING_APPROVED_MESSAGE); + await sendMessageIrc(target, message); }, - }, + }), capabilities: { chatTypes: ["direct", "group"], media: true, @@ -131,40 +168,7 @@ export const ircPlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveIrcDmPolicy, - collectWarnings: ({ account, cfg }) => { - const warnings = collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.irc !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - groupPolicy === "open" - ? [ - buildOpenGroupPolicyWarning({ - surface: "IRC channels", - openBehavior: "allows all channels and senders (mention-gated)", - remediation: - 'Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups', - }), - ] - : [], - }); - if (!account.config.tls) { - warnings.push( - "- IRC TLS is disabled (channels.irc.tls=false); traffic and credentials are plaintext.", - ); - } - if (account.config.nickserv?.register) { - warnings.push( - '- IRC NickServ registration is enabled (channels.irc.nickserv.register=true); this sends "REGISTER" on every connect. Disable after first successful registration.', - ); - if (!account.config.nickserv.password?.trim()) { - warnings.push( - "- IRC NickServ registration is enabled but no NickServ password is resolved; set channels.irc.nickserv.password, channels.irc.nickserv.passwordFile, or IRC_NICKSERV_PASSWORD.", - ); - } - } - return warnings; - }, + collectWarnings: collectIrcSecurityWarnings, }, groups: { resolveRequireMention: ({ cfg, accountId, groupId }) => { @@ -230,66 +234,38 @@ export const ircPlugin: ChannelPlugin = { }); }, }, - directory: { - self: async () => null, - listPeers: async ({ cfg, accountId, query, limit }) => { - const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); - const q = query?.trim().toLowerCase() ?? ""; - const ids = new Set(); - - for (const entry of account.config.allowFrom ?? []) { - const normalized = normalizePairingTarget(String(entry)); - if (normalized && normalized !== "*") { - ids.add(normalized); - } - } - for (const entry of account.config.groupAllowFrom ?? []) { - const normalized = normalizePairingTarget(String(entry)); - if (normalized && normalized !== "*") { - ids.add(normalized); - } - } - for (const group of Object.values(account.config.groups ?? {})) { - for (const entry of group.allowFrom ?? []) { - const normalized = normalizePairingTarget(String(entry)); - if (normalized && normalized !== "*") { - ids.add(normalized); - } - } - } - - return Array.from(ids) - .filter((id) => (q ? id.includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "user", id })); + directory: createChannelDirectoryAdapter({ + listPeers: async (params) => + listResolvedDirectoryEntriesFromSources({ + ...params, + kind: "user", + resolveAccount: (cfg, accountId) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }), + resolveSources: (account) => [ + account.config.allowFrom ?? [], + account.config.groupAllowFrom ?? [], + ...Object.values(account.config.groups ?? {}).map((group) => group.allowFrom ?? []), + ], + normalizeId: (entry) => normalizePairingTarget(entry) || null, + }), + listGroups: async (params) => { + const entries = listResolvedDirectoryEntriesFromSources({ + ...params, + kind: "group", + resolveAccount: (cfg, accountId) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }), + resolveSources: (account) => [ + account.config.channels ?? [], + Object.keys(account.config.groups ?? {}), + ], + normalizeId: (entry) => { + const normalized = normalizeIrcMessagingTarget(entry); + return normalized && isChannelTarget(normalized) ? normalized : null; + }, + }); + return entries.map((entry) => ({ ...entry, name: entry.id })); }, - listGroups: async ({ cfg, accountId, query, limit }) => { - const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); - const q = query?.trim().toLowerCase() ?? ""; - const groupIds = new Set(); - - for (const channel of account.config.channels ?? []) { - const normalized = normalizeIrcMessagingTarget(channel); - if (normalized && isChannelTarget(normalized)) { - groupIds.add(normalized); - } - } - for (const group of Object.keys(account.config.groups ?? {})) { - if (group === "*") { - continue; - } - const normalized = normalizeIrcMessagingTarget(group); - if (normalized && isChannelTarget(normalized)) { - groupIds.add(normalized); - } - } - - return Array.from(groupIds) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "group", id, name: id })); - }, - }, + }), outbound: { deliveryMode: "direct", chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit), diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 33f2b7aa247..edc9f861d28 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -1,5 +1,10 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; -import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; +import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { + createEmptyChannelDirectoryAdapter, + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildChannelConfigSchema, buildComputedAccountStatusSnapshot, @@ -42,29 +47,39 @@ const resolveLineDmPolicy = createScopedDmSecurityResolver( normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""), }); +const collectLineSecurityWarnings = + createAllowlistProviderRestrictSendersWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.line !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + surface: "LINE groups", + openScope: "any member in groups", + groupPolicyPath: "channels.line.groupPolicy", + groupAllowFromPath: "channels.line.groupAllowFrom", + mentionGated: false, + }); + export const linePlugin: ChannelPlugin = { id: "line", meta: { ...meta, quickstartAllowFrom: true, }, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "lineUserId", - normalizeAllowEntry: (entry) => { - // LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:). - return entry.replace(/^line:(?:user:)?/i, ""); - }, - notifyApproval: async ({ cfg, id }) => { + message: "OpenClaw: your access has been approved.", + // LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:). + normalizeAllowEntry: createPairingPrefixStripper(/^line:(?:user:)?/i), + notify: async ({ cfg, id, message }) => { const line = getLineRuntime().channel.line; const account = line.resolveLineAccount({ cfg }); if (!account.channelAccessToken) { throw new Error("LINE channel access token not configured"); } - await line.pushMessageLine(id, "OpenClaw: your access has been approved.", { + await line.pushMessageLine(id, message, { channelAccessToken: account.channelAccessToken, }); }, - }, + }), capabilities: { chatTypes: ["direct", "group"], reactions: false, @@ -90,18 +105,7 @@ export const linePlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveLineDmPolicy, - collectWarnings: ({ account, cfg }) => { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.line !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "LINE groups", - openScope: "any member in groups", - groupPolicyPath: "channels.line.groupPolicy", - groupAllowFromPath: "channels.line.groupAllowFrom", - mentionGated: false, - }); - }, + collectWarnings: collectLineSecurityWarnings, }, groups: { resolveRequireMention: resolveLineGroupRequireMention, @@ -128,11 +132,7 @@ export const linePlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, - listPeers: async () => [], - listGroups: async () => [], - }, + directory: createEmptyChannelDirectoryAdapter(), setup: lineSetupAdapter, outbound: { deliveryMode: "direct", diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index aaf18e3f94b..2334476c224 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -3,9 +3,17 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildOpenGroupPolicyWarning, - collectAllowlistProviderGroupPolicyWarnings, + createAllowlistProviderOpenWarningCollector, + projectWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createPairingPrefixStripper, + createRuntimeDirectoryLiveAdapter, + createRuntimeOutboundDelegates, + createTextPairingAdapter, + listResolvedDirectoryEntriesFromSources, +} from "openclaw/plugin-sdk/channel-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js"; import { @@ -100,18 +108,31 @@ const resolveMatrixDmPolicy = createScopedDmSecurityResolver normalizeMatrixUserId(raw), }); +const collectMatrixSecurityWarnings = + createAllowlistProviderOpenWarningCollector({ + providerConfigPresent: (cfg) => (cfg as CoreConfig).channels?.matrix !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + buildOpenWarning: { + surface: "Matrix rooms", + openBehavior: "allows any room to trigger (mention-gated)", + remediation: + 'Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms', + }, + }); + export const matrixPlugin: ChannelPlugin = { id: "matrix", meta, setupWizard: matrixSetupWizard, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "matrixUserId", - normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""), - notifyApproval: async ({ id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^matrix:/i), + notify: async ({ id, message }) => { const { sendMessageMatrix } = await loadMatrixChannelRuntime(); - await sendMessageMatrix(`user:${id}`, PAIRING_APPROVED_MESSAGE); + await sendMessageMatrix(`user:${id}`, message); }, - }, + }), capabilities: { chatTypes: ["direct", "group", "thread"], polls: true, @@ -134,24 +155,13 @@ export const matrixPlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveMatrixDmPolicy, - collectWarnings: ({ account, cfg }) => { - return collectAllowlistProviderGroupPolicyWarnings({ + collectWarnings: projectWarningCollector( + ({ account, cfg }: { account: ResolvedMatrixAccount; cfg: unknown }) => ({ + account, cfg: cfg as CoreConfig, - providerConfigPresent: (cfg as CoreConfig).channels?.matrix !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - groupPolicy === "open" - ? [ - buildOpenGroupPolicyWarning({ - surface: "Matrix rooms", - openBehavior: "allows any room to trigger (mention-gated)", - remediation: - 'Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms', - }), - ] - : [], - }); - }, + }), + collectMatrixSecurityWarnings, + ), }, groups: { resolveRequireMention: resolveMatrixGroupRequireMention, @@ -187,101 +197,63 @@ export const matrixPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, - listPeers: async ({ cfg, accountId, query, limit }) => { - const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }); - const q = query?.trim().toLowerCase() || ""; - const ids = new Set(); - - for (const entry of account.config.dm?.allowFrom ?? []) { - const raw = String(entry).trim(); - if (!raw || raw === "*") { - continue; - } - ids.add(raw.replace(/^matrix:/i, "")); - } - - for (const entry of account.config.groupAllowFrom ?? []) { - const raw = String(entry).trim(); - if (!raw || raw === "*") { - continue; - } - ids.add(raw.replace(/^matrix:/i, "")); - } - - const groups = account.config.groups ?? account.config.rooms ?? {}; - for (const room of Object.values(groups)) { - for (const entry of room.users ?? []) { - const raw = String(entry).trim(); + directory: createChannelDirectoryAdapter({ + listPeers: async (params) => { + const entries = listResolvedDirectoryEntriesFromSources({ + ...params, + kind: "user", + resolveAccount: (cfg, accountId) => + resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }), + resolveSources: (account) => [ + account.config.dm?.allowFrom ?? [], + account.config.groupAllowFrom ?? [], + ...Object.values(account.config.groups ?? account.config.rooms ?? {}).map( + (room) => room.users ?? [], + ), + ], + normalizeId: (entry) => { + const raw = entry.replace(/^matrix:/i, "").trim(); if (!raw || raw === "*") { - continue; + return null; } - ids.add(raw.replace(/^matrix:/i, "")); - } - } - - return Array.from(ids) - .map((raw) => raw.trim()) - .filter(Boolean) - .map((raw) => { const lowered = raw.toLowerCase(); const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw; - if (cleaned.startsWith("@")) { - return `user:${cleaned}`; - } - return cleaned; - }) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => { - const raw = id.startsWith("user:") ? id.slice("user:".length) : id; - const incomplete = !raw.startsWith("@") || !raw.includes(":"); - return { - kind: "user", - id, - ...(incomplete ? { name: "incomplete id; expected @user:server" } : {}), - }; - }); + return cleaned.startsWith("@") ? `user:${cleaned}` : cleaned; + }, + }); + return entries.map((entry) => { + const raw = entry.id.startsWith("user:") ? entry.id.slice("user:".length) : entry.id; + const incomplete = !raw.startsWith("@") || !raw.includes(":"); + return incomplete ? { ...entry, name: "incomplete id; expected @user:server" } : entry; + }); }, - listGroups: async ({ cfg, accountId, query, limit }) => { - const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }); - const q = query?.trim().toLowerCase() || ""; - const groups = account.config.groups ?? account.config.rooms ?? {}; - const ids = Object.keys(groups) - .map((raw) => raw.trim()) - .filter((raw) => Boolean(raw) && raw !== "*") - .map((raw) => raw.replace(/^matrix:/i, "")) - .map((raw) => { + listGroups: async (params) => + listResolvedDirectoryEntriesFromSources({ + ...params, + kind: "group", + resolveAccount: (cfg, accountId) => + resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }), + resolveSources: (account) => [ + Object.keys(account.config.groups ?? account.config.rooms ?? {}), + ], + normalizeId: (entry) => { + const raw = entry.replace(/^matrix:/i, "").trim(); + if (!raw || raw === "*") { + return null; + } const lowered = raw.toLowerCase(); if (lowered.startsWith("room:") || lowered.startsWith("channel:")) { return raw; } - if (raw.startsWith("!")) { - return `room:${raw}`; - } - return raw; - }) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "group", id }) as const); - return ids; - }, - listPeersLive: async ({ cfg, accountId, query, limit }) => - (await loadMatrixChannelRuntime()).listMatrixDirectoryPeersLive({ - cfg, - accountId, - query, - limit, + return raw.startsWith("!") ? `room:${raw}` : raw; + }, }), - listGroupsLive: async ({ cfg, accountId, query, limit }) => - (await loadMatrixChannelRuntime()).listMatrixDirectoryGroupsLive({ - cfg, - accountId, - query, - limit, - }), - }, + ...createRuntimeDirectoryLiveAdapter({ + getRuntime: loadMatrixChannelRuntime, + listPeersLive: (runtime) => runtime.listMatrixDirectoryPeersLive, + listGroupsLive: (runtime) => runtime.listMatrixDirectoryGroupsLive, + }), + }), resolver: { resolveTargets: async ({ cfg, inputs, kind, runtime }) => (await loadMatrixChannelRuntime()).resolveMatrixTargets({ cfg, inputs, kind, runtime }), @@ -293,27 +265,21 @@ export const matrixPlugin: ChannelPlugin = { chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText!(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async (params) => { - const outbound = (await loadMatrixChannelRuntime()).matrixOutbound; - if (!outbound.sendText) { - throw new Error("Matrix outbound text delivery is unavailable"); - } - return await outbound.sendText(params); - }, - sendMedia: async (params) => { - const outbound = (await loadMatrixChannelRuntime()).matrixOutbound; - if (!outbound.sendMedia) { - throw new Error("Matrix outbound media delivery is unavailable"); - } - return await outbound.sendMedia(params); - }, - sendPoll: async (params) => { - const outbound = (await loadMatrixChannelRuntime()).matrixOutbound; - if (!outbound.sendPoll) { - throw new Error("Matrix outbound poll delivery is unavailable"); - } - return await outbound.sendPoll(params); - }, + ...createRuntimeOutboundDelegates({ + getRuntime: loadMatrixChannelRuntime, + sendText: { + resolve: (runtime) => runtime.matrixOutbound.sendText, + unavailableMessage: "Matrix outbound text delivery is unavailable", + }, + sendMedia: { + resolve: (runtime) => runtime.matrixOutbound.sendMedia, + unavailableMessage: "Matrix outbound media delivery is unavailable", + }, + sendPoll: { + resolve: (runtime) => runtime.matrixOutbound.sendPoll, + unavailableMessage: "Matrix outbound poll delivery is unavailable", + }, + }), }, status: { defaultRuntime: { diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 8c32e068165..511d46b76e6 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -3,9 +3,13 @@ import { createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; -import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelMessageToolDiscovery } from "openclaw/plugin-sdk/channel-runtime"; +import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createLoggedPairingApprovalNotifier, + createMessageToolButtonsSchema, + type ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { MattermostConfigSchema } from "./config-schema.js"; import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; @@ -42,6 +46,16 @@ import { resolveMattermostOutboundSessionRoute } from "./session-route.js"; import { mattermostSetupAdapter } from "./setup-core.js"; import { mattermostSetupWizard } from "./setup-surface.js"; +const collectMattermostSecurityWarnings = + createAllowlistProviderRestrictSendersWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.mattermost !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + surface: "Mattermost channels", + openScope: "any member", + groupPolicyPath: "channels.mattermost.groupPolicy", + groupAllowFromPath: "channels.mattermost.groupAllowFrom", + }); + function describeMattermostMessageTool({ cfg, }: Parameters< @@ -279,9 +293,9 @@ export const mattermostPlugin: ChannelPlugin = { pairing: { idLabel: "mattermostUserId", normalizeAllowEntry: (entry) => normalizeAllowEntry(entry), - notifyApproval: async ({ id }) => { - console.log(`[mattermost] User ${id} approved for pairing`); - }, + notifyApproval: createLoggedPairingApprovalNotifier( + ({ id }) => `[mattermost] User ${id} approved for pairing`, + ), }, capabilities: { chatTypes: ["direct", "channel", "group", "thread"], @@ -319,28 +333,18 @@ export const mattermostPlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveMattermostDmPolicy, - collectWarnings: ({ account, cfg }) => { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.mattermost !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "Mattermost channels", - openScope: "any member", - groupPolicyPath: "channels.mattermost.groupPolicy", - groupAllowFromPath: "channels.mattermost.groupAllowFrom", - }); - }, + collectWarnings: collectMattermostSecurityWarnings, }, groups: { resolveRequireMention: resolveMattermostGroupRequireMention, }, actions: mattermostMessageActions, - directory: { + directory: createChannelDirectoryAdapter({ listGroups: async (params) => listMattermostDirectoryGroups(params), listGroupsLive: async (params) => listMattermostDirectoryGroups(params), listPeers: async (params) => listMattermostDirectoryPeers(params), listPeersLive: async (params) => listMattermostDirectoryPeers(params), - }, + }), messaging: { normalizeTarget: normalizeMattermostMessagingTarget, resolveOutboundSessionRoute: (params) => resolveMattermostOutboundSessionRoute(params), diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index b1379e311df..9d59b042167 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,11 +1,22 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; +import { + createAllowlistProviderGroupPolicyWarningCollector, + projectWarningCollector, +} from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createMessageToolCardSchema, + createPairingPrefixStripper, + createRuntimeDirectoryLiveAdapter, + createRuntimeOutboundDelegates, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery, } from "openclaw/plugin-sdk/channel-runtime"; +import { listDirectoryEntriesFromSources } from "openclaw/plugin-sdk/directory-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionName, ChannelPlugin, OpenClawConfig } from "../runtime-api.js"; import { @@ -60,6 +71,19 @@ const TEAMS_GRAPH_PERMISSION_HINTS: Record = { "Files.Read.All": "files (OneDrive)", }; +const collectMSTeamsSecurityWarnings = createAllowlistProviderGroupPolicyWarningCollector<{ + cfg: OpenClawConfig; +}>({ + providerConfigPresent: (cfg) => cfg.channels?.msteams !== undefined, + resolveGroupPolicy: ({ cfg }) => cfg.channels?.msteams?.groupPolicy, + collect: ({ groupPolicy }) => + groupPolicy === "open" + ? [ + '- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.', + ] + : [], +}); + const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport( () => import("./channel.runtime.js"), "msTeamsChannelRuntime", @@ -117,18 +141,19 @@ export const msteamsPlugin: ChannelPlugin = { aliases: [...meta.aliases], }, setupWizard: msteamsSetupWizard, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "msteamsUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(msteams|user):/i, ""), - notifyApproval: async ({ cfg, id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(msteams|user):/i), + notify: async ({ cfg, id, message }) => { const { sendMessageMSTeams } = await loadMSTeamsChannelRuntime(); await sendMessageMSTeams({ cfg, to: id, - text: PAIRING_APPROVED_MESSAGE, + text: message, }); }, - }, + }), capabilities: { chatTypes: ["direct", "channel", "thread"], polls: true, @@ -163,17 +188,10 @@ export const msteamsPlugin: ChannelPlugin = { }), }, security: { - collectWarnings: ({ cfg }) => { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.msteams !== undefined, - configuredGroupPolicy: cfg.channels?.msteams?.groupPolicy, - surface: "MS Teams groups", - openScope: "any member", - groupPolicyPath: "channels.msteams.groupPolicy", - groupAllowFromPath: "channels.msteams.groupAllowFrom", - }); - }, + collectWarnings: projectWarningCollector( + ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg }), + collectMSTeamsSecurityWarnings, + ), }, setup: msteamsSetupAdapter, messaging: { @@ -198,66 +216,43 @@ export const msteamsPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, - listPeers: async ({ cfg, query, limit }) => { - const q = query?.trim().toLowerCase() || ""; - const ids = new Set(); - for (const entry of cfg.channels?.msteams?.allowFrom ?? []) { - const trimmed = String(entry).trim(); - if (trimmed && trimmed !== "*") { - ids.add(trimmed); - } - } - for (const userId of Object.keys(cfg.channels?.msteams?.dms ?? {})) { - const trimmed = userId.trim(); - if (trimmed) { - ids.add(trimmed); - } - } - return Array.from(ids) - .map((raw) => raw.trim()) - .filter(Boolean) - .map((raw) => normalizeMSTeamsMessagingTarget(raw) ?? raw) - .map((raw) => { - const lowered = raw.toLowerCase(); - if (lowered.startsWith("user:")) { - return raw; + directory: createChannelDirectoryAdapter({ + listPeers: async ({ cfg, query, limit }) => + listDirectoryEntriesFromSources({ + kind: "user", + sources: [ + cfg.channels?.msteams?.allowFrom ?? [], + Object.keys(cfg.channels?.msteams?.dms ?? {}), + ], + query, + limit, + normalizeId: (raw) => { + const normalized = normalizeMSTeamsMessagingTarget(raw) ?? raw; + const lowered = normalized.toLowerCase(); + if (lowered.startsWith("user:") || lowered.startsWith("conversation:")) { + return normalized; } - if (lowered.startsWith("conversation:")) { - return raw; - } - return `user:${raw}`; - }) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "user", id }) as const); - }, - listGroups: async ({ cfg, query, limit }) => { - const q = query?.trim().toLowerCase() || ""; - const ids = new Set(); - for (const team of Object.values(cfg.channels?.msteams?.teams ?? {})) { - for (const channelId of Object.keys(team.channels ?? {})) { - const trimmed = channelId.trim(); - if (trimmed && trimmed !== "*") { - ids.add(trimmed); - } - } - } - return Array.from(ids) - .map((raw) => raw.trim()) - .filter(Boolean) - .map((raw) => raw.replace(/^conversation:/i, "").trim()) - .map((id) => `conversation:${id}`) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "group", id }) as const); - }, - listPeersLive: async ({ cfg, query, limit }) => - (await loadMSTeamsChannelRuntime()).listMSTeamsDirectoryPeersLive({ cfg, query, limit }), - listGroupsLive: async ({ cfg, query, limit }) => - (await loadMSTeamsChannelRuntime()).listMSTeamsDirectoryGroupsLive({ cfg, query, limit }), - }, + return `user:${normalized}`; + }, + }), + listGroups: async ({ cfg, query, limit }) => + listDirectoryEntriesFromSources({ + kind: "group", + sources: [ + Object.values(cfg.channels?.msteams?.teams ?? {}).flatMap((team) => + Object.keys(team.channels ?? {}), + ), + ], + query, + limit, + normalizeId: (raw) => `conversation:${raw.replace(/^conversation:/i, "").trim()}`, + }), + ...createRuntimeDirectoryLiveAdapter({ + getRuntime: loadMSTeamsChannelRuntime, + listPeersLive: (runtime) => runtime.listMSTeamsDirectoryPeersLive, + listGroupsLive: (runtime) => runtime.listMSTeamsDirectoryGroupsLive, + }), + }), resolver: { resolveTargets: async ({ cfg, inputs, kind, runtime }) => { const results = inputs.map((input) => ({ @@ -436,12 +431,12 @@ export const msteamsPlugin: ChannelPlugin = { chunkerMode: "markdown", textChunkLimit: 4000, pollMaxOptions: 12, - sendText: async (params) => - (await loadMSTeamsChannelRuntime()).msteamsOutbound.sendText!(params), - sendMedia: async (params) => - (await loadMSTeamsChannelRuntime()).msteamsOutbound.sendMedia!(params), - sendPoll: async (params) => - (await loadMSTeamsChannelRuntime()).msteamsOutbound.sendPoll!(params), + ...createRuntimeOutboundDelegates({ + getRuntime: loadMSTeamsChannelRuntime, + sendText: { resolve: (runtime) => runtime.msteamsOutbound.sendText }, + sendMedia: { resolve: (runtime) => runtime.msteamsOutbound.sendMedia }, + sendPoll: { resolve: (runtime) => runtime.msteamsOutbound.sendPoll }, + }), }, status: { defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }), diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index ce2f281a3e6..5416a71f9af 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -4,10 +4,11 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; +import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, -} from "openclaw/plugin-sdk/channel-policy"; + createLoggedPairingApprovalNotifier, + createPairingPrefixStripper, +} from "openclaw/plugin-sdk/channel-runtime"; import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; import { buildBaseChannelStatusSummary, @@ -76,17 +77,40 @@ const resolveNextcloudTalkDmPolicy = createScopedDmSecurityResolver raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(), }); +const collectNextcloudTalkSecurityWarnings = + createAllowlistProviderRouteAllowlistWarningCollector({ + providerConfigPresent: (cfg) => + (cfg.channels as Record | undefined)?.["nextcloud-talk"] !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Boolean(account.config.rooms) && Object.keys(account.config.rooms ?? {}).length > 0, + restrictSenders: { + surface: "Nextcloud Talk rooms", + openScope: "any member in allowed rooms", + groupPolicyPath: "channels.nextcloud-talk.groupPolicy", + groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "Nextcloud Talk rooms", + routeAllowlistPath: "channels.nextcloud-talk.rooms", + routeScope: "room", + groupPolicyPath: "channels.nextcloud-talk.groupPolicy", + groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom", + }, + }); + export const nextcloudTalkPlugin: ChannelPlugin = { id: "nextcloud-talk", meta, setupWizard: nextcloudTalkSetupWizard, pairing: { idLabel: "nextcloudUserId", - normalizeAllowEntry: (entry) => - entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(), - notifyApproval: async ({ id }) => { - console.log(`[nextcloud-talk] User ${id} approved for pairing`); - }, + normalizeAllowEntry: createPairingPrefixStripper(/^(nextcloud-talk|nc-talk|nc):/i, (entry) => + entry.toLowerCase(), + ), + notifyApproval: createLoggedPairingApprovalNotifier( + ({ id }) => `[nextcloud-talk] User ${id} approved for pairing`, + ), }, capabilities: { chatTypes: ["direct", "group"], @@ -112,34 +136,7 @@ export const nextcloudTalkPlugin: ChannelPlugin = }, security: { resolveDmPolicy: resolveNextcloudTalkDmPolicy, - collectWarnings: ({ account, cfg }) => { - const roomAllowlistConfigured = - account.config.rooms && Object.keys(account.config.rooms).length > 0; - return collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: - (cfg.channels as Record | undefined)?.["nextcloud-talk"] !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyRouteAllowlistWarnings({ - groupPolicy, - routeAllowlistConfigured: Boolean(roomAllowlistConfigured), - restrictSenders: { - surface: "Nextcloud Talk rooms", - openScope: "any member in allowed rooms", - groupPolicyPath: "channels.nextcloud-talk.groupPolicy", - groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom", - }, - noRouteAllowlist: { - surface: "Nextcloud Talk rooms", - routeAllowlistPath: "channels.nextcloud-talk.rooms", - routeScope: "room", - groupPolicyPath: "channels.nextcloud-talk.groupPolicy", - groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom", - }, - }), - }); - }, + collectWarnings: collectNextcloudTalkSecurityWarnings, }, groups: { resolveRequireMention: ({ cfg, accountId, groupId }) => { diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 1879c85a7b0..e5f8f392202 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,5 +1,9 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, + resolveOutboundSendDep, +} from "openclaw/plugin-sdk/channel-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; @@ -268,35 +272,25 @@ export const signalPlugin: ChannelPlugin = { setupWizard: signalSetupWizard, setup: signalSetupAdapter, }), - pairing: { + pairing: createTextPairingAdapter({ idLabel: "signalNumber", - normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""), - notifyApproval: async ({ id }) => { - await getSignalRuntime().channel.signal.sendMessageSignal(id, PAIRING_APPROVED_MESSAGE); + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^signal:/i), + notify: async ({ id, message }) => { + await getSignalRuntime().channel.signal.sendMessageSignal(id, message); }, - }, + }), actions: signalMessageActions, - allowlist: { - supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", - readConfig: ({ cfg, accountId }) => { - const account = resolveSignalAccount({ cfg, accountId }); - return { - dmAllowFrom: (account.config.allowFrom ?? []).map(String), - groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String), - dmPolicy: account.config.dmPolicy, - groupPolicy: account.config.groupPolicy, - }; - }, - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ - channelId: "signal", - normalize: ({ cfg, accountId, values }) => - signalConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolvePaths: (scope) => ({ - readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], - writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], - }), - }), - }, + allowlist: buildDmGroupAccountAllowlistAdapter({ + channelId: "signal", + resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }), + normalize: ({ cfg, accountId, values }) => + signalConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), + resolveDmAllowFrom: (account) => account.config.allowFrom, + resolveGroupAllowFrom: (account) => account.config.groupAllowFrom, + resolveDmPolicy: (account) => account.config.dmPolicy, + resolveGroupPolicy: (account) => account.config.groupPolicy, + }), security: { resolveDmPolicy: signalResolveDmPolicy, collectWarnings: collectSignalSecurityWarnings, diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts index 1622dc207e4..c1c0e8055dc 100644 --- a/extensions/signal/src/shared.ts +++ b/extensions/signal/src/shared.ts @@ -1,8 +1,8 @@ import { - collectAllowlistProviderRestrictSendersWarnings, createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { listSignalAccountIds, @@ -53,21 +53,16 @@ export const signalResolveDmPolicy = createScopedDmSecurityResolver normalizeE164(raw.replace(/^signal:/i, "").trim()), }); -export function collectSignalSecurityWarnings(params: { - account: ResolvedSignalAccount; - cfg: Parameters[0]["cfg"]; -}) { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg: params.cfg, - providerConfigPresent: params.cfg.channels?.signal !== undefined, - configuredGroupPolicy: params.account.config.groupPolicy, +export const collectSignalSecurityWarnings = + createAllowlistProviderRestrictSendersWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.signal !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, surface: "Signal groups", openScope: "any member", groupPolicyPath: "channels.signal.groupPolicy", groupAllowFromPath: "channels.signal.groupAllowFrom", mentionGated: false, }); -} export function createSignalPluginBase(params: { setupWizard?: NonNullable["setupWizard"]>; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index cbb86a1dff1..dca51eb1fc7 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -1,13 +1,18 @@ import { - buildAccountScopedAllowlistConfigEditor, - resolveLegacyDmAllowlistConfigPaths, + buildLegacyDmAccountAllowlistAdapter, + createAccountScopedAllowlistNameResolver, + createFlatAllowlistOverrideResolver, } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { - createScopedDmSecurityResolver, - collectOpenGroupPolicyConfiguredRouteWarnings, - collectOpenProviderGroupPolicyWarnings, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; + createChannelDirectoryAdapter, + createPairingPrefixStripper, + createRuntimeDirectoryLiveAdapter, + createTextPairingAdapter, + resolveOutboundSendDep, + resolveTargetsWithOptionalToken, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; @@ -286,41 +291,49 @@ function formatSlackScopeDiagnostic(params: { } as const; } -function readSlackAllowlistConfig(account: ResolvedSlackAccount) { - return { - dmAllowFrom: (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String), - groupPolicy: account.groupPolicy, - groupOverrides: Object.entries(account.channels ?? {}) - .map(([key, value]) => { - const entries = (value?.users ?? []).map(String).filter(Boolean); - return entries.length > 0 ? { label: key, entries } : null; - }) - .filter(Boolean) as Array<{ label: string; entries: string[] }>, - }; -} +const resolveSlackAllowlistGroupOverrides = createFlatAllowlistOverrideResolver({ + resolveRecord: (account: ResolvedSlackAccount) => account.channels, + label: (key) => key, + resolveEntries: (value) => value?.users, +}); -async function resolveSlackAllowlistNames(params: { - cfg: Parameters[0]["cfg"]; - accountId?: string | null; - entries: string[]; -}) { - const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); - const token = account.config.userToken?.trim() || account.botToken?.trim(); - if (!token) { - return []; - } - return await resolveSlackUserAllowlist({ token, entries: params.entries }); -} +const resolveSlackAllowlistNames = createAccountScopedAllowlistNameResolver({ + resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), + resolveToken: (account: ResolvedSlackAccount) => + account.config.userToken?.trim() || account.botToken?.trim(), + resolveNames: ({ token, entries }) => resolveSlackUserAllowlist({ token, entries }), +}); + +const collectSlackSecurityWarnings = + createOpenProviderConfiguredRouteWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.slack !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0, + configureRouteAllowlist: { + surface: "Slack channels", + openScope: "any channel not explicitly denied", + groupPolicyPath: "channels.slack.groupPolicy", + routeAllowlistPath: "channels.slack.channels", + }, + missingRouteAllowlist: { + surface: "Slack channels", + openBehavior: "with no channel allowlist; any channel can trigger (mention-gated)", + remediation: + 'Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels', + }, + }); export const slackPlugin: ChannelPlugin = { ...createSlackPluginBase({ setupWizard: slackSetupWizard, setup: slackSetupAdapter, }), - pairing: { + pairing: createTextPairingAdapter({ idLabel: "slackUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""), - notifyApproval: async ({ id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(slack|user):/i), + notify: async ({ id, message }) => { const cfg = getSlackRuntime().config.loadConfig(); const account = resolveSlackAccount({ cfg, @@ -330,63 +343,29 @@ export const slackPlugin: ChannelPlugin = { const botToken = account.botToken?.trim(); const tokenOverride = token && token !== botToken ? token : undefined; if (tokenOverride) { - await getSlackRuntime().channel.slack.sendMessageSlack( - `user:${id}`, - PAIRING_APPROVED_MESSAGE, - { - token: tokenOverride, - }, - ); + await getSlackRuntime().channel.slack.sendMessageSlack(`user:${id}`, message, { + token: tokenOverride, + }); } else { - await getSlackRuntime().channel.slack.sendMessageSlack( - `user:${id}`, - PAIRING_APPROVED_MESSAGE, - ); + await getSlackRuntime().channel.slack.sendMessageSlack(`user:${id}`, message); } }, - }, + }), allowlist: { - supportsScope: ({ scope }) => scope === "dm", - readConfig: ({ cfg, accountId }) => - readSlackAllowlistConfig(resolveSlackAccount({ cfg, accountId })), - resolveNames: async ({ cfg, accountId, entries }) => - await resolveSlackAllowlistNames({ cfg, accountId, entries }), - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ + ...buildLegacyDmAccountAllowlistAdapter({ channelId: "slack", + resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), normalize: ({ cfg, accountId, values }) => slackConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolvePaths: resolveLegacyDmAllowlistConfigPaths, + resolveDmAllowFrom: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveGroupOverrides: resolveSlackAllowlistGroupOverrides, }), + resolveNames: resolveSlackAllowlistNames, }, security: { resolveDmPolicy: resolveSlackDmPolicy, - collectWarnings: ({ account, cfg }) => { - const channelAllowlistConfigured = - Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0; - - return collectOpenProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.slack !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyConfiguredRouteWarnings({ - groupPolicy, - routeAllowlistConfigured: channelAllowlistConfigured, - configureRouteAllowlist: { - surface: "Slack channels", - openScope: "any channel not explicitly denied", - groupPolicyPath: "channels.slack.groupPolicy", - routeAllowlistPath: "channels.slack.channels", - }, - missingRouteAllowlist: { - surface: "Slack channels", - openBehavior: "with no channel allowlist; any channel can trigger (mention-gated)", - remediation: - 'Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels', - }, - }), - }); - }, + collectWarnings: collectSlackSecurityWarnings, }, groups: { resolveRequireMention: resolveSlackGroupRequireMention, @@ -435,14 +414,15 @@ export const slackPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, + directory: createChannelDirectoryAdapter({ listPeers: async (params) => listSlackDirectoryPeersFromConfig(params), listGroups: async (params) => listSlackDirectoryGroupsFromConfig(params), - listPeersLive: async (params) => getSlackRuntime().channel.slack.listDirectoryPeersLive(params), - listGroupsLive: async (params) => - getSlackRuntime().channel.slack.listDirectoryGroupsLive(params), - }, + ...createRuntimeDirectoryLiveAdapter({ + getRuntime: () => getSlackRuntime().channel.slack, + listPeersLive: (runtime) => runtime.listDirectoryPeersLive, + listGroupsLive: (runtime) => runtime.listDirectoryGroupsLive, + }), + }), resolver: { resolveTargets: async ({ cfg, accountId, inputs, kind }) => { const toResolvedTarget = < @@ -458,28 +438,30 @@ export const slackPlugin: ChannelPlugin = { note, }); const account = resolveSlackAccount({ cfg, accountId }); - const token = account.config.userToken?.trim() || account.botToken?.trim(); - if (!token) { - return inputs.map((input) => ({ - input, - resolved: false, - note: "missing Slack token", - })); - } if (kind === "group") { - const resolved = await getSlackRuntime().channel.slack.resolveChannelAllowlist({ - token, - entries: inputs, + return resolveTargetsWithOptionalToken({ + token: account.config.userToken?.trim() || account.botToken?.trim(), + inputs, + missingTokenNote: "missing Slack token", + resolveWithToken: ({ token, inputs }) => + getSlackRuntime().channel.slack.resolveChannelAllowlist({ + token, + entries: inputs, + }), + mapResolved: (entry) => toResolvedTarget(entry, entry.archived ? "archived" : undefined), }); - return resolved.map((entry) => - toResolvedTarget(entry, entry.archived ? "archived" : undefined), - ); } - const resolved = await getSlackRuntime().channel.slack.resolveUserAllowlist({ - token, - entries: inputs, + return resolveTargetsWithOptionalToken({ + token: account.config.userToken?.trim() || account.botToken?.trim(), + inputs, + missingTokenNote: "missing Slack token", + resolveWithToken: ({ token, inputs }) => + getSlackRuntime().channel.slack.resolveUserAllowlist({ + token, + entries: inputs, + }), + mapResolved: (entry) => toResolvedTarget(entry, entry.note), }); - return resolved.map((entry) => toResolvedTarget(entry, entry.note)); }, }, actions: createSlackActions(SLACK_CHANNEL, { diff --git a/extensions/slack/src/directory-config.ts b/extensions/slack/src/directory-config.ts index ec125727454..9cc8330820e 100644 --- a/extensions/slack/src/directory-config.ts +++ b/extensions/slack/src/directory-config.ts @@ -1,28 +1,23 @@ import { - applyDirectoryQueryAndLimit, - collectNormalizedDirectoryIds, - listDirectoryGroupEntriesFromMapKeys, - toDirectoryEntries, + listInspectedDirectoryEntriesFromSources, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; import { inspectSlackAccount, type InspectedSlackAccount } from "../api.js"; import { parseSlackTarget } from "./targets.js"; export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account: InspectedSlackAccount = inspectSlackAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - - const allowFrom = account.config.allowFrom ?? account.dm?.allowFrom ?? []; - const channelUsers = Object.values(account.config.channels ?? {}).flatMap( - (channel) => channel.users ?? [], - ); - const ids = collectNormalizedDirectoryIds({ - sources: [allowFrom, Object.keys(account.config.dms ?? {}), channelUsers], + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "user", + inspectAccount: (cfg, accountId) => + inspectSlackAccount({ cfg, accountId }) as InspectedSlackAccount | null, + resolveSources: (account) => { + const allowFrom = account.config.allowFrom ?? account.dm?.allowFrom ?? []; + const channelUsers = Object.values(account.config.channels ?? {}).flatMap( + (channel) => channel.users ?? [], + ); + return [allowFrom, Object.keys(account.config.dms ?? {}), channelUsers]; + }, normalizeId: (raw) => { const mention = raw.match(/^<@([A-Z0-9]+)>$/i); const normalizedUserId = (mention?.[1] ?? raw).replace(/^(slack|user):/i, "").trim(); @@ -34,21 +29,15 @@ export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigP return normalized?.kind === "user" ? `user:${normalized.id.toLowerCase()}` : null; }, }); - return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params)); } export async function listSlackDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account: InspectedSlackAccount = inspectSlackAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - return listDirectoryGroupEntriesFromMapKeys({ - groups: account.config.channels, - query: params.query, - limit: params.limit, + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "group", + inspectAccount: (cfg, accountId) => + inspectSlackAccount({ cfg, accountId }) as InspectedSlackAccount | null, + resolveSources: (account) => [Object.keys(account.config.channels ?? {})], normalizeId: (raw) => { const normalized = parseSlackTarget(raw, { defaultKind: "channel" }); return normalized?.kind === "channel" ? `channel:${normalized.id.toLowerCase()}` : null; diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 3c453d0613a..4d9ed53a14e 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -97,8 +97,11 @@ describe("createSynologyChatPlugin", () => { it("has notifyApproval and normalizeAllowEntry", () => { const plugin = createSynologyChatPlugin(); expect(plugin.pairing.idLabel).toBe("synologyChatUserId"); - expect(typeof plugin.pairing.normalizeAllowEntry).toBe("function"); - expect(plugin.pairing.normalizeAllowEntry(" USER1 ")).toBe("user1"); + const normalize = plugin.pairing.normalizeAllowEntry; + expect(typeof normalize).toBe("function"); + if (normalize) { + expect(normalize(" USER1 ")).toBe("user1"); + } expect(typeof plugin.pairing.notifyApproval).toBe("function"); }); }); @@ -160,9 +163,10 @@ describe("createSynologyChatPlugin", () => { describe("directory", () => { it("returns empty stubs", async () => { const plugin = createSynologyChatPlugin(); - expect(await plugin.directory.self()).toBeNull(); - expect(await plugin.directory.listPeers()).toEqual([]); - expect(await plugin.directory.listGroups()).toEqual([]); + const params = { cfg: {}, runtime: {} as never }; + expect(await plugin.directory.self?.(params)).toBeNull(); + expect(await plugin.directory.listPeers?.(params)).toEqual([]); + expect(await plugin.directory.listGroups?.(params)).toEqual([]); }); }); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 496b5563857..1b53185cb0f 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -8,6 +8,14 @@ import { createHybridChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { + createConditionalWarningCollector, + projectWarningCollector, +} from "openclaw/plugin-sdk/channel-policy"; +import { + createEmptyChannelDirectoryAdapter, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import { z } from "zod"; import { DEFAULT_ACCOUNT_ID, registerPluginHttpRoute, buildChannelConfigSchema } from "../api.js"; import { listAccountIds, resolveAccount } from "./accounts.js"; @@ -53,6 +61,26 @@ const synologyChatConfigAdapter = createHybridChannelConfigAdapter String(entry).trim().toLowerCase()).filter(Boolean), }); +const collectSynologyChatSecurityWarnings = + createConditionalWarningCollector( + (account) => + !account.token && + "- Synology Chat: token is not configured. The webhook will reject all requests.", + (account) => + !account.incomingUrl && + "- Synology Chat: incomingUrl is not configured. The bot cannot send replies.", + (account) => + account.allowInsecureSsl && + "- Synology Chat: SSL verification is disabled (allowInsecureSsl=true). Only use this for local NAS with self-signed certificates.", + (account) => + account.dmPolicy === "open" && + '- Synology Chat: dmPolicy="open" allows any user to message the bot. Consider "allowlist" for production use.', + (account) => + account.dmPolicy === "allowlist" && + account.allowedUserIds.length === 0 && + '- Synology Chat: dmPolicy="allowlist" with empty allowedUserIds blocks all senders. Add users or set dmPolicy="open".', + ); + function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise { return new Promise((resolve) => { const complete = () => { @@ -106,52 +134,23 @@ export function createSynologyChatPlugin() { ...synologyChatConfigAdapter, }, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "synologyChatUserId", + message: "OpenClaw: your access has been approved.", normalizeAllowEntry: (entry: string) => entry.toLowerCase().trim(), - notifyApproval: async ({ cfg, id }: { cfg: any; id: string }) => { + notify: async ({ cfg, id, message }) => { const account = resolveAccount(cfg); if (!account.incomingUrl) return; - await sendMessage( - account.incomingUrl, - "OpenClaw: your access has been approved.", - id, - account.allowInsecureSsl, - ); + await sendMessage(account.incomingUrl, message, id, account.allowInsecureSsl); }, - }, + }), security: { resolveDmPolicy: resolveSynologyChatDmPolicy, - collectWarnings: ({ account }: { account: ResolvedSynologyChatAccount }) => { - const warnings: string[] = []; - if (!account.token) { - warnings.push( - "- Synology Chat: token is not configured. The webhook will reject all requests.", - ); - } - if (!account.incomingUrl) { - warnings.push( - "- Synology Chat: incomingUrl is not configured. The bot cannot send replies.", - ); - } - if (account.allowInsecureSsl) { - warnings.push( - "- Synology Chat: SSL verification is disabled (allowInsecureSsl=true). Only use this for local NAS with self-signed certificates.", - ); - } - if (account.dmPolicy === "open") { - warnings.push( - '- Synology Chat: dmPolicy="open" allows any user to message the bot. Consider "allowlist" for production use.', - ); - } - if (account.dmPolicy === "allowlist" && account.allowedUserIds.length === 0) { - warnings.push( - '- Synology Chat: dmPolicy="allowlist" with empty allowedUserIds blocks all senders. Add users or set dmPolicy="open".', - ); - } - return warnings; - }, + collectWarnings: projectWarningCollector( + ({ account }: { account: ResolvedSynologyChatAccount }) => account, + collectSynologyChatSecurityWarnings, + ), }, messaging: { @@ -172,11 +171,7 @@ export function createSynologyChatPlugin() { }, }, - directory: { - self: async () => null, - listPeers: async () => [], - listGroups: async () => [], - }, + directory: createEmptyChannelDirectoryAdapter(), outbound: { deliveryMode: "gateway" as const, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 073ca5bd03a..d37b65fc447 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,11 +1,17 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; import { - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, - createScopedDmSecurityResolver, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { type OutboundSendDeps, resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; -import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; + buildDmGroupAccountAllowlistAdapter, + createNestedAllowlistOverrideResolver, +} from "openclaw/plugin-sdk/allowlist-config-edit"; +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createPairingPrefixStripper, + createTextPairingAdapter, + normalizeMessageChannel, + type OutboundSendDeps, + resolveOutboundSendDep, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime"; import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/infra-runtime"; @@ -273,65 +279,66 @@ const resolveTelegramDmPolicy = createScopedDmSecurityResolver raw.replace(/^(telegram|tg):/i, ""), }); -function readTelegramAllowlistConfig(account: ResolvedTelegramAccount) { - const groupOverrides: Array<{ label: string; entries: string[] }> = []; - for (const [groupId, groupCfg] of Object.entries(account.config.groups ?? {})) { - const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean); - if (entries.length > 0) { - groupOverrides.push({ label: groupId, entries }); - } - for (const [topicId, topicCfg] of Object.entries(groupCfg?.topics ?? {})) { - const topicEntries = (topicCfg?.allowFrom ?? []).map(String).filter(Boolean); - if (topicEntries.length > 0) { - groupOverrides.push({ label: `${groupId} topic ${topicId}`, entries: topicEntries }); - } - } - } - return { - dmAllowFrom: (account.config.allowFrom ?? []).map(String), - groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String), - dmPolicy: account.config.dmPolicy, - groupPolicy: account.config.groupPolicy, - groupOverrides, - }; -} +const resolveTelegramAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({ + resolveRecord: (account: ResolvedTelegramAccount) => account.config.groups, + outerLabel: (groupId) => groupId, + resolveOuterEntries: (groupCfg) => groupCfg?.allowFrom, + resolveChildren: (groupCfg) => groupCfg?.topics, + innerLabel: (groupId, topicId) => `${groupId} topic ${topicId}`, + resolveInnerEntries: (topicCfg) => topicCfg?.allowFrom, +}); + +const collectTelegramSecurityWarnings = + createAllowlistProviderRouteAllowlistWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.telegram !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Boolean(account.config.groups) && Object.keys(account.config.groups ?? {}).length > 0, + restrictSenders: { + surface: "Telegram groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.telegram.groupPolicy", + groupAllowFromPath: "channels.telegram.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "Telegram groups", + routeAllowlistPath: "channels.telegram.groups", + routeScope: "group", + groupPolicyPath: "channels.telegram.groupPolicy", + groupAllowFromPath: "channels.telegram.groupAllowFrom", + }, + }); export const telegramPlugin: ChannelPlugin = { ...createTelegramPluginBase({ setupWizard: telegramSetupWizard, setup: telegramSetupAdapter, }), - pairing: { + pairing: createTextPairingAdapter({ idLabel: "telegramUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(telegram|tg):/i, ""), - notifyApproval: async ({ cfg, id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(telegram|tg):/i), + notify: async ({ cfg, id, message }) => { const { token } = getTelegramRuntime().channel.telegram.resolveTelegramToken(cfg); if (!token) { throw new Error("telegram token not configured"); } - await getTelegramRuntime().channel.telegram.sendMessageTelegram( - id, - PAIRING_APPROVED_MESSAGE, - { - token, - }, - ); + await getTelegramRuntime().channel.telegram.sendMessageTelegram(id, message, { + token, + }); }, - }, - allowlist: { - supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", - readConfig: ({ cfg, accountId }) => - readTelegramAllowlistConfig(resolveTelegramAccount({ cfg, accountId })), - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ - channelId: "telegram", - normalize: ({ cfg, accountId, values }) => - telegramConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolvePaths: (scope) => ({ - readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], - writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], - }), - }), - }, + }), + allowlist: buildDmGroupAccountAllowlistAdapter({ + channelId: "telegram", + resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), + normalize: ({ cfg, accountId, values }) => + telegramConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), + resolveDmAllowFrom: (account) => account.config.allowFrom, + resolveGroupAllowFrom: (account) => account.config.groupAllowFrom, + resolveDmPolicy: (account) => account.config.dmPolicy, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveGroupOverrides: resolveTelegramAllowlistGroupOverrides, + }), bindings: { compileConfiguredBinding: ({ conversationId }) => normalizeTelegramAcpConversationId(conversationId), @@ -344,33 +351,7 @@ export const telegramPlugin: ChannelPlugin { - const groupAllowlistConfigured = - account.config.groups && Object.keys(account.config.groups).length > 0; - return collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.telegram !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyRouteAllowlistWarnings({ - groupPolicy, - routeAllowlistConfigured: Boolean(groupAllowlistConfigured), - restrictSenders: { - surface: "Telegram groups", - openScope: "any member in allowed groups", - groupPolicyPath: "channels.telegram.groupPolicy", - groupAllowFromPath: "channels.telegram.groupAllowFrom", - }, - noRouteAllowlist: { - surface: "Telegram groups", - routeAllowlistPath: "channels.telegram.groups", - routeScope: "group", - groupPolicyPath: "channels.telegram.groupPolicy", - groupAllowFromPath: "channels.telegram.groupAllowFrom", - }, - }), - }); - }, + collectWarnings: collectTelegramSecurityWarnings, }, groups: { resolveRequireMention: resolveTelegramGroupRequireMention, @@ -471,11 +452,10 @@ export const telegramPlugin: ChannelPlugin {}); }, }, - directory: { - self: async () => null, + directory: createChannelDirectoryAdapter({ listPeers: async (params) => listTelegramDirectoryPeersFromConfig(params), listGroups: async (params) => listTelegramDirectoryGroupsFromConfig(params), - }, + }), actions: telegramMessageActions, setup: telegramSetupAdapter, outbound: { diff --git a/extensions/telegram/src/directory-config.ts b/extensions/telegram/src/directory-config.ts index af515a29379..6cb51ab686e 100644 --- a/extensions/telegram/src/directory-config.ts +++ b/extensions/telegram/src/directory-config.ts @@ -1,24 +1,20 @@ import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { - applyDirectoryQueryAndLimit, - collectNormalizedDirectoryIds, - listDirectoryGroupEntriesFromMapKeys, - toDirectoryEntries, + listInspectedDirectoryEntriesFromSources, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; import { inspectTelegramAccount, type InspectedTelegramAccount } from "../api.js"; export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account: InspectedTelegramAccount = inspectTelegramAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - - const ids = collectNormalizedDirectoryIds({ - sources: [mapAllowFromEntries(account.config.allowFrom), Object.keys(account.config.dms ?? {})], + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "user", + inspectAccount: (cfg, accountId) => + inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null, + resolveSources: (account) => [ + mapAllowFromEntries(account.config.allowFrom), + Object.keys(account.config.dms ?? {}), + ], normalizeId: (entry) => { const trimmed = entry.replace(/^(telegram|tg):/i, "").trim(); if (!trimmed) { @@ -30,20 +26,15 @@ export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConf return trimmed.startsWith("@") ? trimmed : `@${trimmed}`; }, }); - return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params)); } export async function listTelegramDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account: InspectedTelegramAccount = inspectTelegramAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - return listDirectoryGroupEntriesFromMapKeys({ - groups: account.config.groups, - query: params.query, - limit: params.limit, + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "group", + inspectAccount: (cfg, accountId) => + inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null, + resolveSources: (account) => [Object.keys(account.config.groups ?? {})], + normalizeId: (entry) => entry.trim() || null, }); } diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 865ead9ab46..89e4a235b60 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,5 +1,9 @@ import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -import type { ChannelAccountSnapshot, ChannelPlugin } from "openclaw/plugin-sdk/channel-runtime"; +import { + createRuntimeOutboundDelegates, + type ChannelAccountSnapshot, + type ChannelPlugin, +} from "openclaw/plugin-sdk/channel-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { tlonChannelConfigSchema } from "./config-schema.js"; @@ -107,14 +111,11 @@ export const tlonPlugin: ChannelPlugin = { deliveryMode: "direct", textChunkLimit: 10000, resolveTarget: ({ to }) => resolveTlonOutboundTarget(to), - sendText: async (params) => - await ( - await loadTlonChannelRuntime() - ).tlonRuntimeOutbound.sendText!(params), - sendMedia: async (params) => - await ( - await loadTlonChannelRuntime() - ).tlonRuntimeOutbound.sendMedia!(params), + ...createRuntimeOutboundDelegates({ + getRuntime: loadTlonChannelRuntime, + sendText: { resolve: (runtime) => runtime.tlonRuntimeOutbound.sendText }, + sendMedia: { resolve: (runtime) => runtime.tlonRuntimeOutbound.sendMedia }, + }), }, status: { defaultRuntime: { diff --git a/extensions/whatsapp/src/channel.directory.test.ts b/extensions/whatsapp/src/channel.directory.test.ts new file mode 100644 index 00000000000..3fd58b31d4d --- /dev/null +++ b/extensions/whatsapp/src/channel.directory.test.ts @@ -0,0 +1,62 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/whatsapp"; +import { describe, expect, it } from "vitest"; +import { + createDirectoryTestRuntime, + expectDirectorySurface, +} from "../../../test/helpers/extensions/directory.ts"; +import { whatsappPlugin } from "./channel.js"; + +describe("whatsapp directory", () => { + const runtimeEnv = createDirectoryTestRuntime() as never; + + it("lists peers and groups from config", async () => { + const cfg = { + channels: { + whatsapp: { + authDir: "/tmp/wa-auth", + allowFrom: [ + "whatsapp:+15551230001", + "15551230002@s.whatsapp.net", + "120363999999999999@g.us", + ], + groups: { + "120363111111111111@g.us": {}, + "120363222222222222@g.us": {}, + }, + }, + }, + } as unknown as OpenClawConfig; + + const directory = expectDirectorySurface(whatsappPlugin.directory); + + await expect( + directory.listPeers({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "user", id: "+15551230001" }, + { kind: "user", id: "+15551230002" }, + ]), + ); + + await expect( + directory.listGroups({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "group", id: "120363111111111111@g.us" }, + { kind: "group", id: "120363222222222222@g.us" }, + ]), + ); + }); +}); diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 04780f81eda..151cfc60b40 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,4 +1,4 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; // WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/) import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js"; import { @@ -67,26 +67,15 @@ export const whatsappPlugin: ChannelPlugin = { pairing: { idLabel: "whatsappSenderId", }, - allowlist: { - supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", - readConfig: ({ cfg, accountId }) => { - const account = resolveWhatsAppAccount({ cfg, accountId }); - return { - dmAllowFrom: (account.allowFrom ?? []).map(String), - groupAllowFrom: (account.groupAllowFrom ?? []).map(String), - dmPolicy: account.dmPolicy, - groupPolicy: account.groupPolicy, - }; - }, - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ - channelId: "whatsapp", - normalize: ({ values }) => formatWhatsAppConfigAllowFromEntries(values), - resolvePaths: (scope) => ({ - readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], - writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], - }), - }), - }, + allowlist: buildDmGroupAccountAllowlistAdapter({ + channelId: "whatsapp", + resolveAccount: ({ cfg, accountId }) => resolveWhatsAppAccount({ cfg, accountId }), + normalize: ({ values }) => formatWhatsAppConfigAllowFromEntries(values), + resolveDmAllowFrom: (account) => account.allowFrom, + resolveGroupAllowFrom: (account) => account.groupAllowFrom, + resolveDmPolicy: (account) => account.dmPolicy, + resolveGroupPolicy: (account) => account.groupPolicy, + }), mentions: { stripRegexes: ({ ctx }) => resolveWhatsAppMentionStripRegexes(ctx), }, diff --git a/extensions/whatsapp/src/directory-config.ts b/extensions/whatsapp/src/directory-config.ts index 1a5fbbff9b0..1915b6fd4da 100644 --- a/extensions/whatsapp/src/directory-config.ts +++ b/extensions/whatsapp/src/directory-config.ts @@ -1,17 +1,16 @@ import { - listDirectoryGroupEntriesFromMapKeys, - listDirectoryUserEntriesFromAllowFrom, + listResolvedDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryUserEntriesFromAllowFrom, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; import { resolveWhatsAppAccount } from "./accounts.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize.js"; export async function listWhatsAppDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId }); - return listDirectoryUserEntriesFromAllowFrom({ - allowFrom: account.allowFrom, - query: params.query, - limit: params.limit, + return listResolvedDirectoryUserEntriesFromAllowFrom({ + ...params, + resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), + resolveAllowFrom: (account) => account.allowFrom, normalizeId: (entry) => { const normalized = normalizeWhatsAppTarget(entry); if (!normalized || isWhatsAppGroupJid(normalized)) { @@ -23,10 +22,9 @@ export async function listWhatsAppDirectoryPeersFromConfig(params: DirectoryConf } export async function listWhatsAppDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId }); - return listDirectoryGroupEntriesFromMapKeys({ - groups: account.groups, - query: params.query, - limit: params.limit, + return listResolvedDirectoryGroupEntriesFromMapKeys({ + ...params, + resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), + resolveGroups: (account) => account.groups, }); } diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index b9b86161b3d..5fa27f42030 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -1,9 +1,8 @@ import { - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { createDelegatedSetupWizardProxy } from "openclaw/plugin-sdk/setup"; import { @@ -107,7 +106,27 @@ export function createWhatsAppPluginBase(params: { | "setup" | "groups" > { - return { + const collectWhatsAppSecurityWarnings = + createAllowlistProviderRouteAllowlistWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.whatsapp !== undefined, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0, + restrictSenders: { + surface: "WhatsApp groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "WhatsApp groups", + routeAllowlistPath: "channels.whatsapp.groups", + routeScope: "group", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + }); + return createChannelPluginBase({ id: WHATSAPP_CHANNEL, meta: { ...getChatChannelMeta(WHATSAPP_CHANNEL), @@ -144,35 +163,9 @@ export function createWhatsAppPluginBase(params: { }, security: { resolveDmPolicy: whatsappResolveDmPolicy, - collectWarnings: ({ account, cfg }) => { - const groupAllowlistConfigured = - Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; - return collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.whatsapp !== undefined, - configuredGroupPolicy: account.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyRouteAllowlistWarnings({ - groupPolicy, - routeAllowlistConfigured: groupAllowlistConfigured, - restrictSenders: { - surface: "WhatsApp groups", - openScope: "any member in allowed groups", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - noRouteAllowlist: { - surface: "WhatsApp groups", - routeAllowlistPath: "channels.whatsapp.groups", - routeScope: "group", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - }), - }); - }, + collectWarnings: collectWhatsAppSecurityWarnings, }, setup: params.setup, groups: params.groups, - }; + }); } diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 5434b3e144e..8bd6be02612 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -6,8 +6,10 @@ import { import { buildOpenGroupPolicyRestrictSendersWarning, buildOpenGroupPolicyWarning, - collectOpenProviderGroupPolicyWarnings, + createOpenProviderGroupPolicyWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import { listResolvedDirectoryUserEntriesFromAllowFrom } from "openclaw/plugin-sdk/directory-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { listZaloAccountIds, @@ -78,6 +80,41 @@ const resolveZaloDmPolicy = createScopedDmSecurityResolver( normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""), }); +const collectZaloSecurityWarnings = createOpenProviderGroupPolicyWarningCollector<{ + cfg: OpenClawConfig; + account: ResolvedZaloAccount; +}>({ + providerConfigPresent: (cfg) => cfg.channels?.zalo !== undefined, + resolveGroupPolicy: ({ account }) => account.config.groupPolicy, + collect: ({ account, groupPolicy }) => { + if (groupPolicy !== "open") { + return []; + } + const explicitGroupAllowFrom = mapAllowFromEntries(account.config.groupAllowFrom); + const dmAllowFrom = mapAllowFromEntries(account.config.allowFrom); + const effectiveAllowFrom = + explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom; + if (effectiveAllowFrom.length > 0) { + return [ + buildOpenGroupPolicyRestrictSendersWarning({ + surface: "Zalo groups", + openScope: "any member", + groupPolicyPath: "channels.zalo.groupPolicy", + groupAllowFromPath: "channels.zalo.groupAllowFrom", + }), + ]; + } + return [ + buildOpenGroupPolicyWarning({ + surface: "Zalo groups", + openBehavior: + "with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated)", + remediation: 'Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom', + }), + ]; + }, +}); + export const zaloPlugin: ChannelPlugin = { id: "zalo", meta, @@ -107,41 +144,7 @@ export const zaloPlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveZaloDmPolicy, - collectWarnings: ({ account, cfg }) => { - return collectOpenProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.zalo !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => { - if (groupPolicy !== "open") { - return []; - } - const explicitGroupAllowFrom = mapAllowFromEntries(account.config.groupAllowFrom); - const dmAllowFrom = mapAllowFromEntries(account.config.allowFrom); - const effectiveAllowFrom = - explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom; - if (effectiveAllowFrom.length > 0) { - return [ - buildOpenGroupPolicyRestrictSendersWarning({ - surface: "Zalo groups", - openScope: "any member", - groupPolicyPath: "channels.zalo.groupPolicy", - groupAllowFromPath: "channels.zalo.groupAllowFrom", - }), - ]; - } - return [ - buildOpenGroupPolicyWarning({ - surface: "Zalo groups", - openBehavior: - "with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated)", - remediation: - 'Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom', - }), - ]; - }, - }); - }, + collectWarnings: collectZaloSecurityWarnings, }, groups: { resolveRequireMention: () => true, @@ -158,19 +161,16 @@ export const zaloPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, - listPeers: async ({ cfg, accountId, query, limit }) => { - const account = resolveZaloAccount({ cfg: cfg, accountId }); - return listDirectoryUserEntriesFromAllowFrom({ - allowFrom: account.config.allowFrom, - query, - limit, + directory: createChannelDirectoryAdapter({ + listPeers: async (params) => + listResolvedDirectoryUserEntriesFromAllowFrom({ + ...params, + resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg, accountId }), + resolveAllowFrom: (account) => account.config.allowFrom, normalizeId: (entry) => entry.replace(/^(zalo|zl):/i, ""), - }); - }, + }), listGroups: async () => [], - }, + }), pairing: { idLabel: "zaloUserId", normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""), diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index c1c90affe9c..629125fb120 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -1,5 +1,9 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import type { ChannelAccountSnapshot, @@ -431,20 +435,21 @@ export const zalouserPlugin: ChannelPlugin = { return results; }, }, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "zalouserUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""), - notifyApproval: async ({ cfg, id }) => { + message: "Your pairing request has been approved.", + normalizeAllowEntry: createPairingPrefixStripper(/^(zalouser|zlu):/i), + notify: async ({ cfg, id, message }) => { const account = resolveZalouserAccountSync({ cfg: cfg }); const authenticated = await checkZcaAuthenticated(account.profile); if (!authenticated) { throw new Error("Zalouser not authenticated"); } - await sendMessageZalouser(id, "Your pairing request has been approved.", { + await sendMessageZalouser(id, message, { profile: account.profile, }); }, - }, + }), auth: { login: async ({ cfg, accountId, runtime }) => { const account = resolveZalouserAccountSync({ diff --git a/src/channels/plugins/directory-adapters.test.ts b/src/channels/plugins/directory-adapters.test.ts new file mode 100644 index 00000000000..8d9a6bfea6b --- /dev/null +++ b/src/channels/plugins/directory-adapters.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + createChannelDirectoryAdapter, + createEmptyChannelDirectoryAdapter, + emptyChannelDirectoryList, + nullChannelDirectorySelf, +} from "./directory-adapters.js"; + +describe("directory adapters", () => { + it("defaults self to null", async () => { + const adapter = createChannelDirectoryAdapter(); + await expect(adapter.self?.({ cfg: {}, runtime: {} as never })).resolves.toBeNull(); + }); + + it("preserves provided resolvers", async () => { + const adapter = createChannelDirectoryAdapter({ + listPeers: async () => [{ kind: "user", id: "u-1" }], + }); + await expect(adapter.listPeers?.({ cfg: {}, runtime: {} as never })).resolves.toEqual([ + { kind: "user", id: "u-1" }, + ]); + }); + + it("builds empty directory adapters", async () => { + const adapter = createEmptyChannelDirectoryAdapter(); + await expect(adapter.self?.({ cfg: {}, runtime: {} as never })).resolves.toBeNull(); + await expect(adapter.listPeers?.({ cfg: {}, runtime: {} as never })).resolves.toEqual([]); + await expect(adapter.listGroups?.({ cfg: {}, runtime: {} as never })).resolves.toEqual([]); + }); + + it("exports standalone null/empty helpers", async () => { + await expect(nullChannelDirectorySelf({ cfg: {}, runtime: {} as never })).resolves.toBeNull(); + await expect(emptyChannelDirectoryList({ cfg: {}, runtime: {} as never })).resolves.toEqual([]); + }); +}); diff --git a/src/channels/plugins/directory-adapters.ts b/src/channels/plugins/directory-adapters.ts new file mode 100644 index 00000000000..5462f977d0b --- /dev/null +++ b/src/channels/plugins/directory-adapters.ts @@ -0,0 +1,28 @@ +import type { ChannelDirectoryAdapter } from "./types.adapters.js"; + +export const nullChannelDirectorySelf: NonNullable = async () => + null; + +export const emptyChannelDirectoryList: NonNullable< + ChannelDirectoryAdapter["listPeers"] +> = async () => []; + +/** Build a channel directory adapter with a null self resolver by default. */ +export function createChannelDirectoryAdapter( + params: Omit & { + self?: ChannelDirectoryAdapter["self"]; + } = {}, +): ChannelDirectoryAdapter { + return { + self: params.self ?? nullChannelDirectorySelf, + ...params, + }; +} + +/** Build the common empty directory surface for channels without directory support. */ +export function createEmptyChannelDirectoryAdapter(): ChannelDirectoryAdapter { + return createChannelDirectoryAdapter({ + listPeers: emptyChannelDirectoryList, + listGroups: emptyChannelDirectoryList, + }); +} diff --git a/src/channels/plugins/directory-config-helpers.test.ts b/src/channels/plugins/directory-config-helpers.test.ts index 15aa8f0d298..5fadc922328 100644 --- a/src/channels/plugins/directory-config-helpers.test.ts +++ b/src/channels/plugins/directory-config-helpers.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import { + listDirectoryEntriesFromSources, + listInspectedDirectoryEntriesFromSources, listDirectoryGroupEntriesFromMapKeysAndAllowFrom, listDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryEntriesFromSources, + listResolvedDirectoryUserEntriesFromAllowFrom, listDirectoryUserEntriesFromAllowFromAndMapKeys, listDirectoryUserEntriesFromAllowFrom, } from "./directory-config-helpers.js"; @@ -78,3 +83,95 @@ describe("listDirectoryGroupEntriesFromMapKeysAndAllowFrom", () => { ]); }); }); + +describe("listDirectoryEntriesFromSources", () => { + it("merges source iterables with dedupe/query/limit", () => { + const entries = listDirectoryEntriesFromSources({ + kind: "user", + sources: [ + ["user:alice", "user:bob"], + ["user:carla", "user:alice"], + ], + normalizeId: (entry) => entry.replace(/^user:/i, ""), + query: "a", + limit: 2, + }); + + expectUserDirectoryEntries(entries); + }); +}); + +describe("listInspectedDirectoryEntriesFromSources", () => { + it("returns empty when the inspected account is missing", () => { + const entries = listInspectedDirectoryEntriesFromSources({ + cfg: {} as never, + kind: "user", + inspectAccount: () => null, + resolveSources: () => [["user:alice"]], + normalizeId: (entry) => entry.replace(/^user:/i, ""), + }); + + expect(entries).toEqual([]); + }); + + it("lists entries from inspected account sources", () => { + const entries = listInspectedDirectoryEntriesFromSources({ + cfg: {} as never, + kind: "group", + inspectAccount: () => ({ ids: [["room:a"], ["room:b", "room:a"]] }), + resolveSources: (account) => account.ids, + normalizeId: (entry) => entry.replace(/^room:/i, ""), + query: "a", + }); + + expect(entries).toEqual([{ kind: "group", id: "a" }]); + }); +}); + +describe("resolved account directory helpers", () => { + const cfg = {} as never; + const resolveAccount = () => ({ + allowFrom: ["user:alice", "user:bob"], + groups: { "room:a": {}, "room:b": {} }, + }); + + it("lists user entries from resolved account allowFrom", () => { + const entries = listResolvedDirectoryUserEntriesFromAllowFrom({ + cfg, + resolveAccount, + resolveAllowFrom: (account) => account.allowFrom, + normalizeId: (entry) => entry.replace(/^user:/i, ""), + query: "a", + }); + + expect(entries).toEqual([{ kind: "user", id: "alice" }]); + }); + + it("lists group entries from resolved account map keys", () => { + const entries = listResolvedDirectoryGroupEntriesFromMapKeys({ + cfg, + resolveAccount, + resolveGroups: (account) => account.groups, + normalizeId: (entry) => entry.replace(/^room:/i, ""), + }); + + expect(entries).toEqual([ + { kind: "group", id: "a" }, + { kind: "group", id: "b" }, + ]); + }); + + it("lists entries from resolved account sources", () => { + const entries = listResolvedDirectoryEntriesFromSources({ + cfg, + kind: "user", + resolveAccount, + resolveSources: (account) => [account.allowFrom, ["user:carla", "user:alice"]], + normalizeId: (entry) => entry.replace(/^user:/i, ""), + query: "a", + limit: 2, + }); + + expectUserDirectoryEntries(entries); + }); +}); diff --git a/src/channels/plugins/directory-config-helpers.ts b/src/channels/plugins/directory-config-helpers.ts index 94dc5c3324c..6ee329e578a 100644 --- a/src/channels/plugins/directory-config-helpers.ts +++ b/src/channels/plugins/directory-config-helpers.ts @@ -1,3 +1,5 @@ +import type { OpenClawConfig } from "../../config/types.js"; +import type { DirectoryConfigParams } from "./directory-types.js"; import type { ChannelDirectoryEntry } from "./types.js"; function resolveDirectoryQuery(query?: string | null): string { @@ -81,6 +83,62 @@ export function collectNormalizedDirectoryIds(params: { return Array.from(ids); } +export function listDirectoryEntriesFromSources(params: { + kind: "user" | "group"; + sources: Iterable[]; + query?: string | null; + limit?: number | null; + normalizeId: (entry: string) => string | null | undefined; +}): ChannelDirectoryEntry[] { + const ids = collectNormalizedDirectoryIds({ + sources: params.sources, + normalizeId: params.normalizeId, + }); + return toDirectoryEntries(params.kind, applyDirectoryQueryAndLimit(ids, params)); +} + +export function listInspectedDirectoryEntriesFromSources( + params: DirectoryConfigParams & { + kind: "user" | "group"; + inspectAccount: ( + cfg: OpenClawConfig, + accountId?: string | null, + ) => InspectedAccount | null | undefined; + resolveSources: (account: InspectedAccount) => Iterable[]; + normalizeId: (entry: string) => string | null | undefined; + }, +): ChannelDirectoryEntry[] { + const account = params.inspectAccount(params.cfg, params.accountId); + if (!account) { + return []; + } + return listDirectoryEntriesFromSources({ + kind: params.kind, + sources: params.resolveSources(account), + query: params.query, + limit: params.limit, + normalizeId: params.normalizeId, + }); +} + +export function listResolvedDirectoryEntriesFromSources( + params: DirectoryConfigParams & { + kind: "user" | "group"; + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount; + resolveSources: (account: ResolvedAccount) => Iterable[]; + normalizeId: (entry: string) => string | null | undefined; + }, +): ChannelDirectoryEntry[] { + const account = params.resolveAccount(params.cfg, params.accountId); + return listDirectoryEntriesFromSources({ + kind: params.kind, + sources: params.resolveSources(account), + query: params.query, + limit: params.limit, + normalizeId: params.normalizeId, + }); +} + export function listDirectoryUserEntriesFromAllowFrom(params: { allowFrom?: readonly unknown[]; query?: string | null; @@ -152,3 +210,35 @@ export function listDirectoryGroupEntriesFromMapKeysAndAllowFrom(params: { ]); return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params)); } + +export function listResolvedDirectoryUserEntriesFromAllowFrom( + params: DirectoryConfigParams & { + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount; + resolveAllowFrom: (account: ResolvedAccount) => readonly unknown[] | undefined; + normalizeId?: (entry: string) => string | null | undefined; + }, +): ChannelDirectoryEntry[] { + const account = params.resolveAccount(params.cfg, params.accountId); + return listDirectoryUserEntriesFromAllowFrom({ + allowFrom: params.resolveAllowFrom(account), + query: params.query, + limit: params.limit, + normalizeId: params.normalizeId, + }); +} + +export function listResolvedDirectoryGroupEntriesFromMapKeys( + params: DirectoryConfigParams & { + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount; + resolveGroups: (account: ResolvedAccount) => Record | undefined; + normalizeId?: (entry: string) => string | null | undefined; + }, +): ChannelDirectoryEntry[] { + const account = params.resolveAccount(params.cfg, params.accountId); + return listDirectoryGroupEntriesFromMapKeys({ + groups: params.resolveGroups(account), + query: params.query, + limit: params.limit, + normalizeId: params.normalizeId, + }); +} diff --git a/src/channels/plugins/group-policy-warnings.test.ts b/src/channels/plugins/group-policy-warnings.test.ts index 51a77d992f1..c70e089a288 100644 --- a/src/channels/plugins/group-policy-warnings.test.ts +++ b/src/channels/plugins/group-policy-warnings.test.ts @@ -2,6 +2,16 @@ import { describe, expect, it } from "vitest"; import { collectAllowlistProviderGroupPolicyWarnings, collectAllowlistProviderRestrictSendersWarnings, + composeWarningCollectors, + createAllowlistProviderGroupPolicyWarningCollector, + createConditionalWarningCollector, + createAllowlistProviderOpenWarningCollector, + createAllowlistProviderRestrictSendersWarningCollector, + createAllowlistProviderRouteAllowlistWarningCollector, + createOpenGroupPolicyRestrictSendersWarningCollector, + createOpenProviderGroupPolicyWarningCollector, + createOpenProviderConfiguredRouteWarningCollector, + projectWarningCollector, collectOpenGroupPolicyConfiguredRouteWarnings, collectOpenProviderGroupPolicyWarnings, collectOpenGroupPolicyRestrictSendersWarnings, @@ -13,6 +23,35 @@ import { } from "./group-policy-warnings.js"; describe("group policy warning builders", () => { + it("composes warning collectors", () => { + const collect = composeWarningCollectors<{ enabled: boolean }>( + () => ["a"], + ({ enabled }) => (enabled ? ["b"] : []), + ); + + expect(collect({ enabled: true })).toEqual(["a", "b"]); + expect(collect({ enabled: false })).toEqual(["a"]); + }); + + it("projects warning collector inputs", () => { + const collect = projectWarningCollector( + ({ value }: { value: string }) => value, + (value: string) => [value.toUpperCase()], + ); + + expect(collect({ value: "abc" })).toEqual(["ABC"]); + }); + + it("builds conditional warning collectors", () => { + const collect = createConditionalWarningCollector<{ open: boolean; token?: string }>( + ({ open }) => (open ? "open" : undefined), + ({ token }) => (token ? undefined : ["missing token", "cannot send replies"]), + ); + + expect(collect({ open: true })).toEqual(["open", "missing token", "cannot send replies"]); + expect(collect({ open: false, token: "x" })).toEqual([]); + }); + it("builds base open-policy warning", () => { expect( buildOpenGroupPolicyWarning({ @@ -253,4 +292,205 @@ describe("group policy warning builders", () => { }), ).toEqual([buildOpenGroupPolicyWarning(params.missingRouteAllowlist)]); }); + + it("builds account-aware allowlist-provider restrict-senders collectors", () => { + const collectWarnings = createAllowlistProviderRestrictSendersWarningCollector<{ + groupPolicy?: "open" | "allowlist" | "disabled"; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: (account) => account.groupPolicy, + surface: "Example groups", + openScope: "any member", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + }); + + expect( + collectWarnings({ + account: { groupPolicy: "open" }, + cfg: { channels: { example: {} } }, + }), + ).toEqual([ + buildOpenGroupPolicyRestrictSendersWarning({ + surface: "Example groups", + openScope: "any member", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + }), + ]); + }); + + it("builds config-aware allowlist-provider collectors", () => { + const collectWarnings = createAllowlistProviderGroupPolicyWarningCollector<{ + cfg: { + channels?: { + defaults?: { groupPolicy?: "open" | "allowlist" | "disabled" }; + example?: unknown; + }; + }; + channelLabel: string; + configuredGroupPolicy?: "open" | "allowlist" | "disabled"; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: ({ configuredGroupPolicy }) => configuredGroupPolicy, + collect: ({ channelLabel, groupPolicy }) => + groupPolicy === "open" ? [`warn:${channelLabel}`] : [], + }); + + expect( + collectWarnings({ + cfg: { channels: { example: {} } }, + channelLabel: "example", + configuredGroupPolicy: "open", + }), + ).toEqual(["warn:example"]); + }); + + it("builds account-aware route-allowlist collectors", () => { + const collectWarnings = createAllowlistProviderRouteAllowlistWarningCollector<{ + groupPolicy?: "open" | "allowlist" | "disabled"; + groups?: Record; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveRouteAllowlistConfigured: (account) => Object.keys(account.groups ?? {}).length > 0, + restrictSenders: { + surface: "Example groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "Example groups", + routeAllowlistPath: "channels.example.groups", + routeScope: "group", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + }, + }); + + expect( + collectWarnings({ + account: { groupPolicy: "open", groups: {} }, + cfg: { channels: { example: {} } }, + }), + ).toEqual([ + buildOpenGroupPolicyNoRouteAllowlistWarning({ + surface: "Example groups", + routeAllowlistPath: "channels.example.groups", + routeScope: "group", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + }), + ]); + }); + + it("builds account-aware configured-route collectors", () => { + const collectWarnings = createOpenProviderConfiguredRouteWarningCollector<{ + groupPolicy?: "open" | "allowlist" | "disabled"; + channels?: Record; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveRouteAllowlistConfigured: (account) => Object.keys(account.channels ?? {}).length > 0, + configureRouteAllowlist: { + surface: "Example channels", + openScope: "any channel not explicitly denied", + groupPolicyPath: "channels.example.groupPolicy", + routeAllowlistPath: "channels.example.channels", + }, + missingRouteAllowlist: { + surface: "Example channels", + openBehavior: "with no route allowlist; any channel can trigger (mention-gated)", + remediation: + 'Set channels.example.groupPolicy="allowlist" and configure channels.example.channels', + }, + }); + + expect( + collectWarnings({ + account: { groupPolicy: "open", channels: { general: true } }, + cfg: { channels: { example: {} } }, + }), + ).toEqual([ + buildOpenGroupPolicyConfigureRouteAllowlistWarning({ + surface: "Example channels", + openScope: "any channel not explicitly denied", + groupPolicyPath: "channels.example.groupPolicy", + routeAllowlistPath: "channels.example.channels", + }), + ]); + }); + + it("builds config-aware open-provider collectors", () => { + const collectWarnings = createOpenProviderGroupPolicyWarningCollector<{ + cfg: { channels?: { example?: unknown } }; + configuredGroupPolicy?: "open" | "allowlist" | "disabled"; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: ({ configuredGroupPolicy }) => configuredGroupPolicy, + collect: ({ groupPolicy }) => [groupPolicy], + }); + + expect( + collectWarnings({ + cfg: { channels: { example: {} } }, + configuredGroupPolicy: "open", + }), + ).toEqual(["open"]); + }); + + it("builds account-aware simple open warning collectors", () => { + const collectWarnings = createAllowlistProviderOpenWarningCollector<{ + groupPolicy?: "open" | "allowlist" | "disabled"; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: (account) => account.groupPolicy, + buildOpenWarning: { + surface: "Example channels", + openBehavior: "allows any channel to trigger (mention-gated)", + remediation: + 'Set channels.example.groupPolicy="allowlist" and configure channels.example.channels', + }, + }); + + expect( + collectWarnings({ + account: { groupPolicy: "open" }, + cfg: { channels: { example: {} } }, + }), + ).toEqual([ + buildOpenGroupPolicyWarning({ + surface: "Example channels", + openBehavior: "allows any channel to trigger (mention-gated)", + remediation: + 'Set channels.example.groupPolicy="allowlist" and configure channels.example.channels', + }), + ]); + }); + + it("builds direct account-aware open-policy restrict-senders collectors", () => { + const collectWarnings = createOpenGroupPolicyRestrictSendersWarningCollector<{ + groupPolicy?: "open" | "allowlist" | "disabled"; + }>({ + resolveGroupPolicy: (account) => account.groupPolicy, + defaultGroupPolicy: "allowlist", + surface: "Example groups", + openScope: "any member", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + mentionGated: false, + }); + + expect(collectWarnings({ groupPolicy: "allowlist" })).toEqual([]); + expect(collectWarnings({ groupPolicy: "open" })).toEqual([ + buildOpenGroupPolicyRestrictSendersWarning({ + surface: "Example groups", + openScope: "any member", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + mentionGated: false, + }), + ]); + }); }); diff --git a/src/channels/plugins/group-policy-warnings.ts b/src/channels/plugins/group-policy-warnings.ts index 67d8c952b02..776ac6ddba4 100644 --- a/src/channels/plugins/group-policy-warnings.ts +++ b/src/channels/plugins/group-policy-warnings.ts @@ -7,6 +7,40 @@ import { import type { GroupPolicy } from "../../config/types.base.js"; type GroupPolicyWarningCollector = (groupPolicy: GroupPolicy) => string[]; +type AccountGroupPolicyWarningCollector = (params: { + account: ResolvedAccount; + cfg: OpenClawConfig; +}) => string[]; +type ConfigGroupPolicyWarningCollector = ( + params: Params, +) => string[]; +type WarningCollector = (params: Params) => string[]; + +export function composeWarningCollectors( + ...collectors: Array | null | undefined> +): WarningCollector { + return (params) => collectors.flatMap((collector) => collector?.(params) ?? []); +} + +export function projectWarningCollector( + project: (params: Params) => Projected, + collector: WarningCollector, +): WarningCollector { + return (params) => collector(project(params)); +} + +export function createConditionalWarningCollector( + ...collectors: Array<(params: Params) => string | string[] | null | undefined | false> +): WarningCollector { + return (params) => + collectors.flatMap((collector) => { + const next = collector(params); + if (!next) { + return []; + } + return Array.isArray(next) ? next : [next]; + }); +} export function buildOpenGroupPolicyWarning(params: { surface: string; @@ -96,6 +130,50 @@ export function collectAllowlistProviderRestrictSendersWarnings( }); } +/** Build an account-aware allowlist-provider warning collector for sender-restricted groups. */ +export function createAllowlistProviderRestrictSendersWarningCollector( + params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined; + } & Omit< + Parameters[0], + "cfg" | "providerConfigPresent" | "configuredGroupPolicy" + >, +): AccountGroupPolicyWarningCollector { + return createAllowlistProviderGroupPolicyWarningCollector({ + providerConfigPresent: params.providerConfigPresent, + resolveGroupPolicy: ({ account }: { account: ResolvedAccount; cfg: OpenClawConfig }) => + params.resolveGroupPolicy(account), + collect: ({ groupPolicy }) => + collectOpenGroupPolicyRestrictSendersWarnings({ + groupPolicy, + surface: params.surface, + openScope: params.openScope, + groupPolicyPath: params.groupPolicyPath, + groupAllowFromPath: params.groupAllowFromPath, + mentionGated: params.mentionGated, + }), + }); +} + +/** Build a direct account-aware warning collector when the policy already lives on the account. */ +export function createOpenGroupPolicyRestrictSendersWarningCollector( + params: { + resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined; + defaultGroupPolicy?: GroupPolicy; + } & Omit[0], "groupPolicy">, +): (account: ResolvedAccount) => string[] { + return (account) => + collectOpenGroupPolicyRestrictSendersWarnings({ + groupPolicy: params.resolveGroupPolicy(account) ?? params.defaultGroupPolicy ?? "allowlist", + surface: params.surface, + openScope: params.openScope, + groupPolicyPath: params.groupPolicyPath, + groupAllowFromPath: params.groupAllowFromPath, + mentionGated: params.mentionGated, + }); +} + export function collectAllowlistProviderGroupPolicyWarnings(params: { cfg: OpenClawConfig; providerConfigPresent: boolean; @@ -111,6 +189,23 @@ export function collectAllowlistProviderGroupPolicyWarnings(params: { return params.collect(groupPolicy); } +/** Build a config-aware allowlist-provider warning collector from an arbitrary policy resolver. */ +export function createAllowlistProviderGroupPolicyWarningCollector< + Params extends { cfg: OpenClawConfig }, +>(params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (params: Params) => GroupPolicy | null | undefined; + collect: (params: Params & { groupPolicy: GroupPolicy }) => string[]; +}): ConfigGroupPolicyWarningCollector { + return (runtime) => + collectAllowlistProviderGroupPolicyWarnings({ + cfg: runtime.cfg, + providerConfigPresent: params.providerConfigPresent(runtime.cfg), + configuredGroupPolicy: params.resolveGroupPolicy(runtime), + collect: (groupPolicy) => params.collect({ ...runtime, groupPolicy }), + }); +} + export function collectOpenProviderGroupPolicyWarnings(params: { cfg: OpenClawConfig; providerConfigPresent: boolean; @@ -126,6 +221,38 @@ export function collectOpenProviderGroupPolicyWarnings(params: { return params.collect(groupPolicy); } +/** Build a config-aware open-provider warning collector from an arbitrary policy resolver. */ +export function createOpenProviderGroupPolicyWarningCollector< + Params extends { cfg: OpenClawConfig }, +>(params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (params: Params) => GroupPolicy | null | undefined; + collect: (params: Params & { groupPolicy: GroupPolicy }) => string[]; +}): ConfigGroupPolicyWarningCollector { + return (runtime) => + collectOpenProviderGroupPolicyWarnings({ + cfg: runtime.cfg, + providerConfigPresent: params.providerConfigPresent(runtime.cfg), + configuredGroupPolicy: params.resolveGroupPolicy(runtime), + collect: (groupPolicy) => params.collect({ ...runtime, groupPolicy }), + }); +} + +/** Build an account-aware allowlist-provider warning collector for simple open-policy warnings. */ +export function createAllowlistProviderOpenWarningCollector(params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined; + buildOpenWarning: Parameters[0]; +}): AccountGroupPolicyWarningCollector { + return createAllowlistProviderGroupPolicyWarningCollector({ + providerConfigPresent: params.providerConfigPresent, + resolveGroupPolicy: ({ account }: { account: ResolvedAccount; cfg: OpenClawConfig }) => + params.resolveGroupPolicy(account), + collect: ({ groupPolicy }) => + groupPolicy === "open" ? [buildOpenGroupPolicyWarning(params.buildOpenWarning)] : [], + }); +} + export function collectOpenGroupPolicyRouteAllowlistWarnings(params: { groupPolicy: "open" | "allowlist" | "disabled"; routeAllowlistConfigured: boolean; @@ -141,6 +268,28 @@ export function collectOpenGroupPolicyRouteAllowlistWarnings(params: { return [buildOpenGroupPolicyNoRouteAllowlistWarning(params.noRouteAllowlist)]; } +/** Build an account-aware allowlist-provider warning collector for route-allowlisted groups. */ +export function createAllowlistProviderRouteAllowlistWarningCollector(params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined; + resolveRouteAllowlistConfigured: (account: ResolvedAccount) => boolean; + restrictSenders: Parameters[0]; + noRouteAllowlist: Parameters[0]; +}): AccountGroupPolicyWarningCollector { + return createAllowlistProviderGroupPolicyWarningCollector({ + providerConfigPresent: params.providerConfigPresent, + resolveGroupPolicy: ({ account }: { account: ResolvedAccount; cfg: OpenClawConfig }) => + params.resolveGroupPolicy(account), + collect: ({ account, groupPolicy }) => + collectOpenGroupPolicyRouteAllowlistWarnings({ + groupPolicy, + routeAllowlistConfigured: params.resolveRouteAllowlistConfigured(account), + restrictSenders: params.restrictSenders, + noRouteAllowlist: params.noRouteAllowlist, + }), + }); +} + export function collectOpenGroupPolicyConfiguredRouteWarnings(params: { groupPolicy: "open" | "allowlist" | "disabled"; routeAllowlistConfigured: boolean; @@ -155,3 +304,25 @@ export function collectOpenGroupPolicyConfiguredRouteWarnings(params: { } return [buildOpenGroupPolicyWarning(params.missingRouteAllowlist)]; } + +/** Build an account-aware open-provider warning collector for configured-route channels. */ +export function createOpenProviderConfiguredRouteWarningCollector(params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined; + resolveRouteAllowlistConfigured: (account: ResolvedAccount) => boolean; + configureRouteAllowlist: Parameters[0]; + missingRouteAllowlist: Parameters[0]; +}): AccountGroupPolicyWarningCollector { + return createOpenProviderGroupPolicyWarningCollector({ + providerConfigPresent: params.providerConfigPresent, + resolveGroupPolicy: ({ account }: { account: ResolvedAccount; cfg: OpenClawConfig }) => + params.resolveGroupPolicy(account), + collect: ({ account, groupPolicy }) => + collectOpenGroupPolicyConfiguredRouteWarnings({ + groupPolicy, + routeAllowlistConfigured: params.resolveRouteAllowlistConfigured(account), + configureRouteAllowlist: params.configureRouteAllowlist, + missingRouteAllowlist: params.missingRouteAllowlist, + }), + }); +} diff --git a/src/channels/plugins/pairing-adapters.test.ts b/src/channels/plugins/pairing-adapters.test.ts new file mode 100644 index 00000000000..7fee2155414 --- /dev/null +++ b/src/channels/plugins/pairing-adapters.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createLoggedPairingApprovalNotifier, + createPairingPrefixStripper, + createTextPairingAdapter, +} from "./pairing-adapters.js"; + +describe("pairing adapters", () => { + it("strips prefixes and applies optional mapping", () => { + const strip = createPairingPrefixStripper(/^(telegram|tg):/i); + const lower = createPairingPrefixStripper(/^nextcloud:/i, (entry) => entry.toLowerCase()); + expect(strip("telegram:123")).toBe("123"); + expect(strip("tg:123")).toBe("123"); + expect(lower("nextcloud:USER")).toBe("user"); + }); + + it("builds text pairing adapters", async () => { + const notify = vi.fn(async () => {}); + const pairing = createTextPairingAdapter({ + idLabel: "telegramUserId", + message: "approved", + normalizeAllowEntry: createPairingPrefixStripper(/^telegram:/i), + notify, + }); + expect(pairing.idLabel).toBe("telegramUserId"); + expect(pairing.normalizeAllowEntry?.("telegram:123")).toBe("123"); + await pairing.notifyApproval?.({ cfg: {}, id: "123" }); + expect(notify).toHaveBeenCalledWith({ cfg: {}, id: "123", message: "approved" }); + }); + + it("builds logger-backed approval notifiers", async () => { + const log = vi.fn(); + const notify = createLoggedPairingApprovalNotifier(({ id }) => `approved ${id}`, log); + await notify({ cfg: {}, id: "u-1" }); + expect(log).toHaveBeenCalledWith("approved u-1"); + }); +}); diff --git a/src/channels/plugins/pairing-adapters.ts b/src/channels/plugins/pairing-adapters.ts new file mode 100644 index 00000000000..583fe44a448 --- /dev/null +++ b/src/channels/plugins/pairing-adapters.ts @@ -0,0 +1,34 @@ +import type { ChannelPairingAdapter } from "./types.adapters.js"; + +type PairingNotifyParams = Parameters>[0]; + +export function createPairingPrefixStripper( + prefixRe: RegExp, + map: (entry: string) => string = (entry) => entry, +): NonNullable { + return (entry) => map(entry.replace(prefixRe, "")); +} + +export function createLoggedPairingApprovalNotifier( + format: string | ((params: PairingNotifyParams) => string), + log: (message: string) => void = console.log, +): NonNullable { + return async (params) => { + log(typeof format === "function" ? format(params) : format); + }; +} + +export function createTextPairingAdapter(params: { + idLabel: string; + message: string; + normalizeAllowEntry?: ChannelPairingAdapter["normalizeAllowEntry"]; + notify: (params: PairingNotifyParams & { message: string }) => Promise | void; +}): ChannelPairingAdapter { + return { + idLabel: params.idLabel, + normalizeAllowEntry: params.normalizeAllowEntry, + notifyApproval: async (ctx) => { + await params.notify({ ...ctx, message: params.message }); + }, + }; +} diff --git a/src/channels/plugins/runtime-forwarders.test.ts b/src/channels/plugins/runtime-forwarders.test.ts new file mode 100644 index 00000000000..8b927a319f3 --- /dev/null +++ b/src/channels/plugins/runtime-forwarders.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createRuntimeDirectoryLiveAdapter, + createRuntimeOutboundDelegates, +} from "./runtime-forwarders.js"; + +describe("createRuntimeDirectoryLiveAdapter", () => { + it("forwards live directory calls through the runtime getter", async () => { + const listPeersLive = vi.fn(async (_ctx: unknown) => [{ kind: "user" as const, id: "alice" }]); + const adapter = createRuntimeDirectoryLiveAdapter({ + getRuntime: async () => ({ listPeersLive }), + listPeersLive: (runtime) => runtime.listPeersLive, + }); + + await expect( + adapter.listPeersLive?.({ cfg: {} as never, runtime: {} as never, query: "a", limit: 1 }), + ).resolves.toEqual([{ kind: "user", id: "alice" }]); + expect(listPeersLive).toHaveBeenCalled(); + }); +}); + +describe("createRuntimeOutboundDelegates", () => { + it("forwards outbound methods through the runtime getter", async () => { + const sendText = vi.fn(async () => ({ channel: "x", messageId: "1" })); + const outbound = createRuntimeOutboundDelegates({ + getRuntime: async () => ({ outbound: { sendText } }), + sendText: { resolve: (runtime) => runtime.outbound.sendText }, + }); + + await expect(outbound.sendText?.({ cfg: {} as never, to: "a", text: "hi" })).resolves.toEqual({ + channel: "x", + messageId: "1", + }); + expect(sendText).toHaveBeenCalled(); + }); + + it("throws the configured unavailable message", async () => { + const outbound = createRuntimeOutboundDelegates({ + getRuntime: async () => ({ outbound: {} }), + sendPoll: { + resolve: () => undefined, + unavailableMessage: "poll unavailable", + }, + }); + + await expect( + outbound.sendPoll?.({ + cfg: {} as never, + to: "a", + poll: { question: "q", options: ["a"] }, + }), + ).rejects.toThrow("poll unavailable"); + }); +}); diff --git a/src/channels/plugins/runtime-forwarders.ts b/src/channels/plugins/runtime-forwarders.ts new file mode 100644 index 00000000000..9730e4a94e8 --- /dev/null +++ b/src/channels/plugins/runtime-forwarders.ts @@ -0,0 +1,117 @@ +import type { ChannelDirectoryAdapter, ChannelOutboundAdapter } from "./types.adapters.js"; + +type MaybePromise = T | Promise; + +type DirectoryListMethod = "listPeersLive" | "listGroupsLive" | "listGroupMembers"; +type OutboundMethod = "sendText" | "sendMedia" | "sendPoll"; + +type DirectoryListParams = Parameters>[0]; +type DirectoryGroupMembersParams = Parameters< + NonNullable +>[0]; +type SendTextParams = Parameters>[0]; +type SendMediaParams = Parameters>[0]; +type SendPollParams = Parameters>[0]; + +async function resolveForwardedMethod(params: { + getRuntime: () => MaybePromise; + resolve: (runtime: Runtime) => Fn | null | undefined; + unavailableMessage?: string; +}): Promise { + const runtime = await params.getRuntime(); + const method = params.resolve(runtime); + if (method) { + return method; + } + throw new Error(params.unavailableMessage ?? "Runtime method is unavailable"); +} + +export function createRuntimeDirectoryLiveAdapter(params: { + getRuntime: () => MaybePromise; + listPeersLive?: (runtime: Runtime) => ChannelDirectoryAdapter["listPeersLive"] | null | undefined; + listGroupsLive?: ( + runtime: Runtime, + ) => ChannelDirectoryAdapter["listGroupsLive"] | null | undefined; + listGroupMembers?: ( + runtime: Runtime, + ) => ChannelDirectoryAdapter["listGroupMembers"] | null | undefined; +}): Pick { + return { + listPeersLive: params.listPeersLive + ? async (ctx: DirectoryListParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.listPeersLive!, + }) + )(ctx) + : undefined, + listGroupsLive: params.listGroupsLive + ? async (ctx: DirectoryListParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.listGroupsLive!, + }) + )(ctx) + : undefined, + listGroupMembers: params.listGroupMembers + ? async (ctx: DirectoryGroupMembersParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.listGroupMembers!, + }) + )(ctx) + : undefined, + }; +} + +export function createRuntimeOutboundDelegates(params: { + getRuntime: () => MaybePromise; + sendText?: { + resolve: (runtime: Runtime) => ChannelOutboundAdapter["sendText"] | null | undefined; + unavailableMessage?: string; + }; + sendMedia?: { + resolve: (runtime: Runtime) => ChannelOutboundAdapter["sendMedia"] | null | undefined; + unavailableMessage?: string; + }; + sendPoll?: { + resolve: (runtime: Runtime) => ChannelOutboundAdapter["sendPoll"] | null | undefined; + unavailableMessage?: string; + }; +}): Pick { + return { + sendText: params.sendText + ? async (ctx: SendTextParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.sendText!.resolve, + unavailableMessage: params.sendText!.unavailableMessage, + }) + )(ctx) + : undefined, + sendMedia: params.sendMedia + ? async (ctx: SendMediaParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.sendMedia!.resolve, + unavailableMessage: params.sendMedia!.unavailableMessage, + }) + )(ctx) + : undefined, + sendPoll: params.sendPoll + ? async (ctx: SendPollParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.sendPoll!.resolve, + unavailableMessage: params.sendPoll!.unavailableMessage, + }) + )(ctx) + : undefined, + }; +} diff --git a/src/channels/plugins/target-resolvers.test.ts b/src/channels/plugins/target-resolvers.test.ts new file mode 100644 index 00000000000..161b94a8fb2 --- /dev/null +++ b/src/channels/plugins/target-resolvers.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + buildUnresolvedTargetResults, + resolveTargetsWithOptionalToken, +} from "./target-resolvers.js"; + +describe("buildUnresolvedTargetResults", () => { + it("marks each input unresolved with the same note", () => { + expect(buildUnresolvedTargetResults(["a", "b"], "missing token")).toEqual([ + { input: "a", resolved: false, note: "missing token" }, + { input: "b", resolved: false, note: "missing token" }, + ]); + }); +}); + +describe("resolveTargetsWithOptionalToken", () => { + it("returns unresolved entries when the token is missing", async () => { + const resolved = await resolveTargetsWithOptionalToken({ + inputs: ["alice"], + missingTokenNote: "missing token", + resolveWithToken: async () => [{ input: "alice", id: "1" }], + mapResolved: (entry) => ({ input: entry.input, resolved: true, id: entry.id }), + }); + + expect(resolved).toEqual([{ input: "alice", resolved: false, note: "missing token" }]); + }); + + it("resolves and maps entries when a token is present", async () => { + const resolved = await resolveTargetsWithOptionalToken({ + token: " x ", + inputs: ["alice"], + missingTokenNote: "missing token", + resolveWithToken: async ({ token, inputs }) => + inputs.map((input) => ({ input, id: `${token}:${input}` })), + mapResolved: (entry) => ({ input: entry.input, resolved: true, id: entry.id }), + }); + + expect(resolved).toEqual([{ input: "alice", resolved: true, id: "x:alice" }]); + }); +}); diff --git a/src/channels/plugins/target-resolvers.ts b/src/channels/plugins/target-resolvers.ts new file mode 100644 index 00000000000..81bdd82fd6c --- /dev/null +++ b/src/channels/plugins/target-resolvers.ts @@ -0,0 +1,30 @@ +import type { ChannelResolveResult } from "./types.adapters.js"; + +export function buildUnresolvedTargetResults( + inputs: string[], + note: string, +): ChannelResolveResult[] { + return inputs.map((input) => ({ + input, + resolved: false, + note, + })); +} + +export async function resolveTargetsWithOptionalToken(params: { + token?: string | null; + inputs: string[]; + missingTokenNote: string; + resolveWithToken: (params: { token: string; inputs: string[] }) => Promise; + mapResolved: (entry: TResult) => ChannelResolveResult; +}): Promise { + const token = params.token?.trim(); + if (!token) { + return buildUnresolvedTargetResults(params.inputs, params.missingTokenNote); + } + const resolved = await params.resolveWithToken({ + token, + inputs: params.inputs, + }); + return resolved.map(params.mapResolved); +} diff --git a/src/plugin-sdk/allowlist-config-edit.test.ts b/src/plugin-sdk/allowlist-config-edit.test.ts new file mode 100644 index 00000000000..45305fcc0ed --- /dev/null +++ b/src/plugin-sdk/allowlist-config-edit.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "vitest"; +import { + buildDmGroupAccountAllowlistAdapter, + buildLegacyDmAccountAllowlistAdapter, + collectAllowlistOverridesFromRecord, + collectNestedAllowlistOverridesFromRecord, + createAccountScopedAllowlistNameResolver, + createFlatAllowlistOverrideResolver, + createNestedAllowlistOverrideResolver, + readConfiguredAllowlistEntries, +} from "./allowlist-config-edit.js"; + +describe("readConfiguredAllowlistEntries", () => { + it("coerces mixed entries to non-empty strings", () => { + expect(readConfiguredAllowlistEntries(["owner", 42, ""])).toEqual(["owner", "42"]); + }); +}); + +describe("collectAllowlistOverridesFromRecord", () => { + it("collects only non-empty overrides from a flat record", () => { + expect( + collectAllowlistOverridesFromRecord({ + record: { + room1: { users: ["a", "b"] }, + room2: { users: [] }, + }, + label: (key) => key, + resolveEntries: (value) => value.users, + }), + ).toEqual([{ label: "room1", entries: ["a", "b"] }]); + }); +}); + +describe("collectNestedAllowlistOverridesFromRecord", () => { + it("collects outer and nested overrides from a hierarchical record", () => { + expect( + collectNestedAllowlistOverridesFromRecord({ + record: { + guild1: { + users: ["owner"], + channels: { + chan1: { users: ["member"] }, + }, + }, + }, + outerLabel: (key) => `guild ${key}`, + resolveOuterEntries: (value) => value.users, + resolveChildren: (value) => value.channels, + innerLabel: (outerKey, innerKey) => `guild ${outerKey} / channel ${innerKey}`, + resolveInnerEntries: (value) => value.users, + }), + ).toEqual([ + { label: "guild guild1", entries: ["owner"] }, + { label: "guild guild1 / channel chan1", entries: ["member"] }, + ]); + }); +}); + +describe("createFlatAllowlistOverrideResolver", () => { + it("builds an account-scoped flat override resolver", () => { + const resolveOverrides = createFlatAllowlistOverrideResolver({ + resolveRecord: (account: { channels?: Record }) => + account.channels, + label: (key) => key, + resolveEntries: (value) => value.users, + }); + + expect(resolveOverrides({ channels: { room1: { users: ["a"] } } })).toEqual([ + { label: "room1", entries: ["a"] }, + ]); + }); +}); + +describe("createNestedAllowlistOverrideResolver", () => { + it("builds an account-scoped nested override resolver", () => { + const resolveOverrides = createNestedAllowlistOverrideResolver({ + resolveRecord: (account: { + groups?: Record< + string, + { allowFrom?: string[]; topics?: Record } + >; + }) => account.groups, + outerLabel: (groupId) => groupId, + resolveOuterEntries: (group) => group.allowFrom, + resolveChildren: (group) => group.topics, + innerLabel: (groupId, topicId) => `${groupId} topic ${topicId}`, + resolveInnerEntries: (topic) => topic.allowFrom, + }); + + expect( + resolveOverrides({ + groups: { + g1: { allowFrom: ["owner"], topics: { t1: { allowFrom: ["member"] } } }, + }, + }), + ).toEqual([ + { label: "g1", entries: ["owner"] }, + { label: "g1 topic t1", entries: ["member"] }, + ]); + }); +}); + +describe("createAccountScopedAllowlistNameResolver", () => { + it("returns empty results when the resolved account has no token", async () => { + const resolveNames = createAccountScopedAllowlistNameResolver({ + resolveAccount: () => ({ token: "" }), + resolveToken: (account) => account.token, + resolveNames: async ({ token, entries }) => + entries.map((entry) => ({ input: `${token}:${entry}`, resolved: true })), + }); + + expect(await resolveNames({ cfg: {}, accountId: "alt", scope: "dm", entries: ["a"] })).toEqual( + [], + ); + }); + + it("delegates to the resolver when a token is present", async () => { + const resolveNames = createAccountScopedAllowlistNameResolver({ + resolveAccount: () => ({ token: " secret " }), + resolveToken: (account) => account.token, + resolveNames: async ({ token, entries }) => + entries.map((entry) => ({ input: entry, resolved: true, name: `${token}:${entry}` })), + }); + + expect(await resolveNames({ cfg: {}, accountId: "alt", scope: "dm", entries: ["a"] })).toEqual([ + { input: "a", resolved: true, name: "secret:a" }, + ]); + }); +}); + +describe("buildDmGroupAccountAllowlistAdapter", () => { + const adapter = buildDmGroupAccountAllowlistAdapter({ + channelId: "demo", + resolveAccount: ({ accountId }) => ({ + accountId: accountId ?? "default", + dmAllowFrom: ["dm-owner"], + groupAllowFrom: ["group-owner"], + dmPolicy: "allowlist", + groupPolicy: "allowlist", + groupOverrides: [{ label: "room-1", entries: ["member-1"] }], + }), + normalize: ({ values }) => values.map((entry) => String(entry).trim().toLowerCase()), + resolveDmAllowFrom: (account) => account.dmAllowFrom, + resolveGroupAllowFrom: (account) => account.groupAllowFrom, + resolveDmPolicy: (account) => account.dmPolicy, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveGroupOverrides: (account) => account.groupOverrides, + }); + + it("supports dm, group, and all scopes", () => { + expect(adapter.supportsScope?.({ scope: "dm" })).toBe(true); + expect(adapter.supportsScope?.({ scope: "group" })).toBe(true); + expect(adapter.supportsScope?.({ scope: "all" })).toBe(true); + }); + + it("reads dm/group config from the resolved account", () => { + expect(adapter.readConfig?.({ cfg: {}, accountId: "alt" })).toEqual({ + dmAllowFrom: ["dm-owner"], + groupAllowFrom: ["group-owner"], + dmPolicy: "allowlist", + groupPolicy: "allowlist", + groupOverrides: [{ label: "room-1", entries: ["member-1"] }], + }); + }); + + it("writes group allowlist entries to groupAllowFrom", () => { + expect( + adapter.applyConfigEdit?.({ + cfg: {}, + parsedConfig: {}, + accountId: "alt", + scope: "group", + action: "add", + entry: " Member-2 ", + }), + ).toEqual({ + kind: "ok", + changed: true, + pathLabel: "channels.demo.accounts.alt.groupAllowFrom", + writeTarget: { + kind: "account", + scope: { channelId: "demo", accountId: "alt" }, + }, + }); + }); +}); + +describe("buildLegacyDmAccountAllowlistAdapter", () => { + const adapter = buildLegacyDmAccountAllowlistAdapter({ + channelId: "demo", + resolveAccount: ({ accountId }) => ({ + accountId: accountId ?? "default", + dmAllowFrom: ["owner"], + groupPolicy: "allowlist", + groupOverrides: [{ label: "group-1", entries: ["member-1"] }], + }), + normalize: ({ values }) => values.map((entry) => String(entry).trim().toLowerCase()), + resolveDmAllowFrom: (account) => account.dmAllowFrom, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveGroupOverrides: (account) => account.groupOverrides, + }); + + it("supports only dm scope", () => { + expect(adapter.supportsScope?.({ scope: "dm" })).toBe(true); + expect(adapter.supportsScope?.({ scope: "group" })).toBe(false); + expect(adapter.supportsScope?.({ scope: "all" })).toBe(false); + }); + + it("reads legacy dm config from the resolved account", () => { + expect(adapter.readConfig?.({ cfg: {}, accountId: "alt" })).toEqual({ + dmAllowFrom: ["owner"], + groupPolicy: "allowlist", + groupOverrides: [{ label: "group-1", entries: ["member-1"] }], + }); + }); + + it("writes dm allowlist entries and keeps legacy cleanup behavior", () => { + expect( + adapter.applyConfigEdit?.({ + cfg: {}, + parsedConfig: { + channels: { + demo: { + accounts: { + alt: { + dm: { allowFrom: ["owner"] }, + }, + }, + }, + }, + }, + accountId: "alt", + scope: "dm", + action: "add", + entry: "admin", + }), + ).toEqual({ + kind: "ok", + changed: true, + pathLabel: "channels.demo.accounts.alt.allowFrom", + writeTarget: { + kind: "account", + scope: { channelId: "demo", accountId: "alt" }, + }, + }); + }); +}); diff --git a/src/plugin-sdk/allowlist-config-edit.ts b/src/plugin-sdk/allowlist-config-edit.ts index e92e4cb8551..4891bb5075a 100644 --- a/src/plugin-sdk/allowlist-config-edit.ts +++ b/src/plugin-sdk/allowlist-config-edit.ts @@ -11,16 +11,152 @@ type AllowlistConfigPaths = { cleanupPaths?: string[][]; }; +export type AllowlistGroupOverride = { label: string; entries: string[] }; +export type AllowlistNameResolution = Array<{ + input: string; + resolved: boolean; + name?: string | null; +}>; +type AllowlistNormalizer = (params: { + cfg: OpenClawConfig; + accountId?: string | null; + values: Array; +}) => string[]; +type AllowlistAccountResolver = (params: { + cfg: OpenClawConfig; + accountId?: string | null; +}) => ResolvedAccount; + +const DM_ALLOWLIST_CONFIG_PATHS: AllowlistConfigPaths = { + readPaths: [["allowFrom"]], + writePath: ["allowFrom"], +}; + +const GROUP_ALLOWLIST_CONFIG_PATHS: AllowlistConfigPaths = { + readPaths: [["groupAllowFrom"]], + writePath: ["groupAllowFrom"], +}; + const LEGACY_DM_ALLOWLIST_CONFIG_PATHS: AllowlistConfigPaths = { readPaths: [["allowFrom"], ["dm", "allowFrom"]], writePath: ["allowFrom"], cleanupPaths: [["dm", "allowFrom"]], }; +export function resolveDmGroupAllowlistConfigPaths(scope: "dm" | "group") { + return scope === "dm" ? DM_ALLOWLIST_CONFIG_PATHS : GROUP_ALLOWLIST_CONFIG_PATHS; +} + export function resolveLegacyDmAllowlistConfigPaths(scope: "dm" | "group") { return scope === "dm" ? LEGACY_DM_ALLOWLIST_CONFIG_PATHS : null; } +/** Coerce stored allowlist entries into presentable non-empty strings. */ +export function readConfiguredAllowlistEntries( + entries: Array | null | undefined, +): string[] { + return (entries ?? []).map(String).filter(Boolean); +} + +/** Collect labeled allowlist overrides from a flat keyed record. */ +export function collectAllowlistOverridesFromRecord(params: { + record: Record | null | undefined; + label: (key: string, value: T) => string; + resolveEntries: (value: T) => Array | null | undefined; +}): AllowlistGroupOverride[] { + const overrides: AllowlistGroupOverride[] = []; + for (const [key, value] of Object.entries(params.record ?? {})) { + if (!value) { + continue; + } + const entries = readConfiguredAllowlistEntries(params.resolveEntries(value)); + if (entries.length === 0) { + continue; + } + overrides.push({ label: params.label(key, value), entries }); + } + return overrides; +} + +/** Collect labeled allowlist overrides from an outer record with nested child records. */ +export function collectNestedAllowlistOverridesFromRecord(params: { + record: Record | null | undefined; + outerLabel: (key: string, value: Outer) => string; + resolveOuterEntries: (value: Outer) => Array | null | undefined; + resolveChildren: (value: Outer) => Record | null | undefined; + innerLabel: (outerKey: string, innerKey: string, inner: Inner) => string; + resolveInnerEntries: (value: Inner) => Array | null | undefined; +}): AllowlistGroupOverride[] { + const overrides: AllowlistGroupOverride[] = []; + for (const [outerKey, outerValue] of Object.entries(params.record ?? {})) { + if (!outerValue) { + continue; + } + const outerEntries = readConfiguredAllowlistEntries(params.resolveOuterEntries(outerValue)); + if (outerEntries.length > 0) { + overrides.push({ label: params.outerLabel(outerKey, outerValue), entries: outerEntries }); + } + overrides.push( + ...collectAllowlistOverridesFromRecord({ + record: params.resolveChildren(outerValue), + label: (innerKey, innerValue) => params.innerLabel(outerKey, innerKey, innerValue), + resolveEntries: params.resolveInnerEntries, + }), + ); + } + return overrides; +} + +/** Build an account-scoped flat override resolver from a keyed allowlist record. */ +export function createFlatAllowlistOverrideResolver(params: { + resolveRecord: (account: ResolvedAccount) => Record | null | undefined; + label: (key: string, value: Entry) => string; + resolveEntries: (value: Entry) => Array | null | undefined; +}): (account: ResolvedAccount) => AllowlistGroupOverride[] { + return (account) => + collectAllowlistOverridesFromRecord({ + record: params.resolveRecord(account), + label: params.label, + resolveEntries: params.resolveEntries, + }); +} + +/** Build an account-scoped nested override resolver from hierarchical allowlist records. */ +export function createNestedAllowlistOverrideResolver(params: { + resolveRecord: (account: ResolvedAccount) => Record | null | undefined; + outerLabel: (key: string, value: Outer) => string; + resolveOuterEntries: (value: Outer) => Array | null | undefined; + resolveChildren: (value: Outer) => Record | null | undefined; + innerLabel: (outerKey: string, innerKey: string, inner: Inner) => string; + resolveInnerEntries: (value: Inner) => Array | null | undefined; +}): (account: ResolvedAccount) => AllowlistGroupOverride[] { + return (account) => + collectNestedAllowlistOverridesFromRecord({ + record: params.resolveRecord(account), + outerLabel: params.outerLabel, + resolveOuterEntries: params.resolveOuterEntries, + resolveChildren: params.resolveChildren, + innerLabel: params.innerLabel, + resolveInnerEntries: params.resolveInnerEntries, + }); +} + +/** Build the common account-scoped token-gated allowlist name resolver. */ +export function createAccountScopedAllowlistNameResolver(params: { + resolveAccount: (params: { cfg: OpenClawConfig; accountId?: string | null }) => ResolvedAccount; + resolveToken: (account: ResolvedAccount) => string | null | undefined; + resolveNames: (params: { token: string; entries: string[] }) => Promise; +}): NonNullable { + return async ({ cfg, accountId, entries }) => { + const account = params.resolveAccount({ cfg, accountId }); + const token = params.resolveToken(account)?.trim(); + if (!token) { + return []; + } + return await params.resolveNames({ token, entries }); + }; +} + function resolveAccountScopedWriteTarget( parsed: Record, channelId: ChannelId, @@ -196,11 +332,7 @@ function applyAccountScopedAllowlistConfigEdit(params: { /** Build the default account-scoped allowlist editor used by channel plugins with config-backed lists. */ export function buildAccountScopedAllowlistConfigEditor(params: { channelId: ChannelId; - normalize: (params: { - cfg: OpenClawConfig; - accountId?: string | null; - values: Array; - }) => string[]; + normalize: AllowlistNormalizer; resolvePaths: (scope: "dm" | "group") => AllowlistConfigPaths | null; }): NonNullable { return ({ cfg, parsedConfig, accountId, scope, action, entry }) => { @@ -219,3 +351,75 @@ export function buildAccountScopedAllowlistConfigEditor(params: { }); }; } + +function buildAccountAllowlistAdapter(params: { + channelId: ChannelId; + resolveAccount: AllowlistAccountResolver; + normalize: AllowlistNormalizer; + supportsScope: NonNullable; + resolvePaths: (scope: "dm" | "group") => AllowlistConfigPaths | null; + readConfig: ( + account: ResolvedAccount, + ) => Awaited>>; +}): Pick { + return { + supportsScope: params.supportsScope, + readConfig: ({ cfg, accountId }) => + params.readConfig(params.resolveAccount({ cfg, accountId })), + applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ + channelId: params.channelId, + normalize: params.normalize, + resolvePaths: params.resolvePaths, + }), + }; +} + +/** Build the common DM/group allowlist adapter used by channels that store both lists in config. */ +export function buildDmGroupAccountAllowlistAdapter(params: { + channelId: ChannelId; + resolveAccount: AllowlistAccountResolver; + normalize: AllowlistNormalizer; + resolveDmAllowFrom: (account: ResolvedAccount) => Array | null | undefined; + resolveGroupAllowFrom: (account: ResolvedAccount) => Array | null | undefined; + resolveDmPolicy?: (account: ResolvedAccount) => string | null | undefined; + resolveGroupPolicy?: (account: ResolvedAccount) => string | null | undefined; + resolveGroupOverrides?: (account: ResolvedAccount) => AllowlistGroupOverride[] | undefined; +}): Pick { + return buildAccountAllowlistAdapter({ + channelId: params.channelId, + resolveAccount: params.resolveAccount, + normalize: params.normalize, + supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", + resolvePaths: resolveDmGroupAllowlistConfigPaths, + readConfig: (account) => ({ + dmAllowFrom: readConfiguredAllowlistEntries(params.resolveDmAllowFrom(account)), + groupAllowFrom: readConfiguredAllowlistEntries(params.resolveGroupAllowFrom(account)), + dmPolicy: params.resolveDmPolicy?.(account) ?? undefined, + groupPolicy: params.resolveGroupPolicy?.(account) ?? undefined, + groupOverrides: params.resolveGroupOverrides?.(account), + }), + }); +} + +/** Build the common DM-only allowlist adapter for channels with legacy dm.allowFrom fallback paths. */ +export function buildLegacyDmAccountAllowlistAdapter(params: { + channelId: ChannelId; + resolveAccount: AllowlistAccountResolver; + normalize: AllowlistNormalizer; + resolveDmAllowFrom: (account: ResolvedAccount) => Array | null | undefined; + resolveGroupPolicy?: (account: ResolvedAccount) => string | null | undefined; + resolveGroupOverrides?: (account: ResolvedAccount) => AllowlistGroupOverride[] | undefined; +}): Pick { + return buildAccountAllowlistAdapter({ + channelId: params.channelId, + resolveAccount: params.resolveAccount, + normalize: params.normalize, + supportsScope: ({ scope }) => scope === "dm", + resolvePaths: resolveLegacyDmAllowlistConfigPaths, + readConfig: (account) => ({ + dmAllowFrom: readConfiguredAllowlistEntries(params.resolveDmAllowFrom(account)), + groupPolicy: params.resolveGroupPolicy?.(account) ?? undefined, + groupOverrides: params.resolveGroupOverrides?.(account), + }), + }); +} diff --git a/src/plugin-sdk/channel-policy.ts b/src/plugin-sdk/channel-policy.ts index c59643a4e4b..06dc117b9b2 100644 --- a/src/plugin-sdk/channel-policy.ts +++ b/src/plugin-sdk/channel-policy.ts @@ -5,6 +5,15 @@ export type { } from "../config/types.tools.js"; export { buildOpenGroupPolicyConfigureRouteAllowlistWarning, + composeWarningCollectors, + createAllowlistProviderGroupPolicyWarningCollector, + createConditionalWarningCollector, + createAllowlistProviderOpenWarningCollector, + createAllowlistProviderRestrictSendersWarningCollector, + createAllowlistProviderRouteAllowlistWarningCollector, + createOpenGroupPolicyRestrictSendersWarningCollector, + createOpenProviderGroupPolicyWarningCollector, + createOpenProviderConfiguredRouteWarningCollector, buildOpenGroupPolicyRestrictSendersWarning, buildOpenGroupPolicyWarning, collectAllowlistProviderGroupPolicyWarnings, @@ -12,6 +21,7 @@ export { collectOpenGroupPolicyRestrictSendersWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, collectOpenProviderGroupPolicyWarnings, + projectWarningCollector, } from "../channels/plugins/group-policy-warnings.js"; export { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; export { diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index 59832d70f80..a7630924997 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -32,12 +32,16 @@ export * from "../channels/plugins/actions/reaction-message-id.js"; export * from "../channels/plugins/actions/shared.js"; export type * from "../channels/plugins/types.js"; export * from "../channels/plugins/config-writes.js"; +export * from "../channels/plugins/directory-adapters.js"; export * from "../channels/plugins/media-payload.js"; export * from "../channels/plugins/message-tool-schema.js"; export * from "../channels/plugins/normalize/signal.js"; export * from "../channels/plugins/normalize/whatsapp.js"; export * from "../channels/plugins/outbound/direct-text-media.js"; export * from "../channels/plugins/outbound/interactive.js"; +export * from "../channels/plugins/pairing-adapters.js"; +export * from "../channels/plugins/runtime-forwarders.js"; +export * from "../channels/plugins/target-resolvers.js"; export * from "../channels/plugins/status-issues/shared.js"; export * from "../channels/plugins/whatsapp-heartbeat.js"; export * from "../infra/outbound/send-deps.js"; diff --git a/src/plugin-sdk/directory-runtime.ts b/src/plugin-sdk/directory-runtime.ts index a13a368abd4..caa21657810 100644 --- a/src/plugin-sdk/directory-runtime.ts +++ b/src/plugin-sdk/directory-runtime.ts @@ -4,8 +4,13 @@ export type { ReadOnlyInspectedAccount } from "../channels/read-only-account-ins export { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, + listDirectoryEntriesFromSources, listDirectoryGroupEntriesFromMapKeys, listDirectoryGroupEntriesFromMapKeysAndAllowFrom, + listInspectedDirectoryEntriesFromSources, + listResolvedDirectoryEntriesFromSources, + listResolvedDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryUserEntriesFromAllowFrom, listDirectoryUserEntriesFromAllowFrom, listDirectoryUserEntriesFromAllowFromAndMapKeys, toDirectoryEntries, diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 0e5da56d274..079fa8b3a01 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -1,3 +1,4 @@ +import * as channelRuntimeSdk from "openclaw/plugin-sdk/channel-runtime"; import * as compatSdk from "openclaw/plugin-sdk/compat"; import * as coreSdk from "openclaw/plugin-sdk/core"; import type { @@ -5,6 +6,7 @@ import type { OpenClawPluginApi as CoreOpenClawPluginApi, PluginRuntime as CorePluginRuntime, } from "openclaw/plugin-sdk/core"; +import * as directoryRuntimeSdk from "openclaw/plugin-sdk/directory-runtime"; import * as discordSdk from "openclaw/plugin-sdk/discord"; import * as imessageSdk from "openclaw/plugin-sdk/imessage"; import * as lazyRuntimeSdk from "openclaw/plugin-sdk/lazy-runtime"; @@ -58,6 +60,7 @@ const mattermostSdk = await import("openclaw/plugin-sdk/mattermost"); const nextcloudTalkSdk = await import("openclaw/plugin-sdk/nextcloud-talk"); const twitchSdk = await import("openclaw/plugin-sdk/twitch"); const accountHelpersSdk = await import("openclaw/plugin-sdk/account-helpers"); +const allowlistEditSdk = await import("openclaw/plugin-sdk/allowlist-config-edit"); const lobsterSdk = await import("openclaw/plugin-sdk/lobster"); describe("plugin-sdk subpath exports", () => { @@ -94,10 +97,42 @@ describe("plugin-sdk subpath exports", () => { expect(typeof accountHelpersSdk.createAccountListHelpers).toBe("function"); }); + it("exports allowlist edit helpers from the dedicated subpath", () => { + expect(typeof allowlistEditSdk.buildDmGroupAccountAllowlistAdapter).toBe("function"); + expect(typeof allowlistEditSdk.buildLegacyDmAccountAllowlistAdapter).toBe("function"); + expect(typeof allowlistEditSdk.createAccountScopedAllowlistNameResolver).toBe("function"); + expect(typeof allowlistEditSdk.createFlatAllowlistOverrideResolver).toBe("function"); + expect(typeof allowlistEditSdk.createNestedAllowlistOverrideResolver).toBe("function"); + }); + it("exports runtime helpers from the dedicated subpath", () => { expect(typeof runtimeSdk.createLoggerBackedRuntime).toBe("function"); }); + it("exports directory runtime helpers from the dedicated subpath", () => { + expect(typeof directoryRuntimeSdk.listDirectoryEntriesFromSources).toBe("function"); + expect(typeof directoryRuntimeSdk.listInspectedDirectoryEntriesFromSources).toBe("function"); + expect(typeof directoryRuntimeSdk.listResolvedDirectoryEntriesFromSources).toBe("function"); + expect(typeof directoryRuntimeSdk.listResolvedDirectoryGroupEntriesFromMapKeys).toBe( + "function", + ); + expect(typeof directoryRuntimeSdk.listResolvedDirectoryUserEntriesFromAllowFrom).toBe( + "function", + ); + }); + + it("exports channel runtime helpers from the dedicated subpath", () => { + expect(typeof channelRuntimeSdk.buildUnresolvedTargetResults).toBe("function"); + expect(typeof channelRuntimeSdk.createChannelDirectoryAdapter).toBe("function"); + expect(typeof channelRuntimeSdk.createEmptyChannelDirectoryAdapter).toBe("function"); + expect(typeof channelRuntimeSdk.createLoggedPairingApprovalNotifier).toBe("function"); + expect(typeof channelRuntimeSdk.createPairingPrefixStripper).toBe("function"); + expect(typeof channelRuntimeSdk.createRuntimeDirectoryLiveAdapter).toBe("function"); + expect(typeof channelRuntimeSdk.createRuntimeOutboundDelegates).toBe("function"); + expect(typeof channelRuntimeSdk.resolveTargetsWithOptionalToken).toBe("function"); + expect(typeof channelRuntimeSdk.createTextPairingAdapter).toBe("function"); + }); + it("exports provider setup helpers from the dedicated subpath", () => { expect(typeof providerSetupSdk.buildVllmProvider).toBe("function"); expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function");