refactor: deduplicate channel runtime helpers

This commit is contained in:
Peter Steinberger 2026-03-18 16:36:09 +00:00
parent 3e02635df3
commit 27f655ed11
47 changed files with 2595 additions and 1151 deletions

View File

@ -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<ResolvedBlueBu
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
});
const collectBlueBubblesSecurityWarnings =
createOpenGroupPolicyRestrictSendersWarningCollector<ResolvedBlueBubblesAccount>({
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<ResolvedBlueBubblesAccount> = {
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<ResolvedBlueBubblesAccount> = {
},
},
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,

View File

@ -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<typeof resolveDiscordAccount>[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<ResolvedDiscordAccount>({
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.<id>.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.<id>.channels',
},
});
function normalizeDiscordAcpConversationId(conversationId: string) {
const normalized = conversationId.trim();
@ -288,60 +291,29 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
...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.<id>.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.<id>.channels',
},
}),
});
},
collectWarnings: collectDiscordSecurityWarnings,
},
groups: {
resolveRequireMention: resolveDiscordGroupRequireMention,
@ -387,53 +359,57 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
(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,

View File

@ -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));
}

View File

@ -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<ResolvedFeishuAccount> = {
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<ResolvedFeishuAccount> = {
},
},
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<ResolvedFeishuAccount> = {
hint: "<chatId|user:openId|chat:chatId>",
},
},
directory: {
self: async () => null,
directory: createChannelDirectoryAdapter({
listPeers: async ({ cfg, query, limit, accountId }) =>
listFeishuDirectoryPeers({
cfg,
@ -889,29 +911,38 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
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 }),

View File

@ -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" },
]),
);
});
});

View File

@ -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<ResolvedGoogleChatAccount>({
providerConfigPresent: (cfg) => cfg.channels?.googlechat !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
buildOpenWarning: {
surface: "Google Chat spaces",
openBehavior: "allows any space to trigger (mention-gated)",
remediation:
'Set channels.googlechat.groupPolicy="allowlist" and configure channels.googlechat.groups',
},
});
const collectGoogleChatSecurityWarnings = 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<ResolvedGoogleChatAccount> = {
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<ResolvedGoogleChatAccount> = {
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<ResolvedGoogleChatAccount> = {
},
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<ResolvedGoogleChatAccount> = {
hint: "<spaces/{space}|users/{user}>",
},
},
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) => {

View File

@ -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<ResolvedIMessageAccount> = {
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,

View File

@ -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<ResolvedIM
policyPathSuffix: "dmPolicy",
});
export function collectIMessageSecurityWarnings(params: {
account: ResolvedIMessageAccount;
cfg: Parameters<typeof resolveIMessageAccount>[0]["cfg"];
}) {
return collectAllowlistProviderRestrictSendersWarnings({
cfg: params.cfg,
providerConfigPresent: params.cfg.channels?.imessage !== undefined,
configuredGroupPolicy: params.account.config.groupPolicy,
export const collectIMessageSecurityWarnings =
createAllowlistProviderRestrictSendersWarningCollector<ResolvedIMessageAccount>({
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<ChannelPlugin<ResolvedIMessageAccount>["setupWizard"]>;

View File

@ -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<ResolvedIrcAccount>({
normalizeEntry: (raw) => normalizeIrcAllowEntry(raw),
});
const collectIrcGroupPolicyWarnings =
createAllowlistProviderOpenWarningCollector<ResolvedIrcAccount>({
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<ResolvedIrcAccount, IrcProbe> = {
id: "irc",
meta: {
@ -96,17 +132,18 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
},
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<ResolvedIrcAccount, IrcProbe> = {
},
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<ResolvedIrcAccount, IrcProbe> = {
});
},
},
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<string>();
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<string>();
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),

View File

@ -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<ResolvedLineAccount>(
normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
});
const collectLineSecurityWarnings =
createAllowlistProviderRestrictSendersWarningCollector<ResolvedLineAccount>({
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<ResolvedLineAccount> = {
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<ResolvedLineAccount> = {
},
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<ResolvedLineAccount> = {
hint: "<userId|groupId|roomId>",
},
},
directory: {
self: async () => null,
listPeers: async () => [],
listGroups: async () => [],
},
directory: createEmptyChannelDirectoryAdapter(),
setup: lineSetupAdapter,
outbound: {
deliveryMode: "direct",

View File

@ -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<ResolvedMatrixAccou
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
});
const collectMatrixSecurityWarnings =
createAllowlistProviderOpenWarningCollector<ResolvedMatrixAccount>({
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<ResolvedMatrixAccount> = {
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<ResolvedMatrixAccount> = {
},
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<ResolvedMatrixAccount> = {
hint: "<room|alias|user>",
},
},
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<string>();
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<ResolvedMatrixAccount> = {
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: {

View File

@ -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<ResolvedMattermostAccount>({
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<ResolvedMattermostAccount> = {
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<ResolvedMattermostAccount> = {
},
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),

View File

@ -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<string, string> = {
"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<ResolvedMSTeamsAccount> = {
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<ResolvedMSTeamsAccount> = {
}),
},
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<ResolvedMSTeamsAccount> = {
hint: "<conversationId|user:ID|conversation:ID>",
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, query, limit }) => {
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
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<string>();
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<ResolvedMSTeamsAccount> = {
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 }),

View File

@ -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<ResolvedNext
normalizeEntry: (raw) => raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(),
});
const collectNextcloudTalkSecurityWarnings =
createAllowlistProviderRouteAllowlistWarningCollector<ResolvedNextcloudTalkAccount>({
providerConfigPresent: (cfg) =>
(cfg.channels as Record<string, unknown> | 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<ResolvedNextcloudTalkAccount> = {
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<ResolvedNextcloudTalkAccount> =
},
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<string, unknown> | 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 }) => {

View File

@ -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<ResolvedSignalAccount> = {
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,

View File

@ -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<ResolvedSign
normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()),
});
export function collectSignalSecurityWarnings(params: {
account: ResolvedSignalAccount;
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
}) {
return collectAllowlistProviderRestrictSendersWarnings({
cfg: params.cfg,
providerConfigPresent: params.cfg.channels?.signal !== undefined,
configuredGroupPolicy: params.account.config.groupPolicy,
export const collectSignalSecurityWarnings =
createAllowlistProviderRestrictSendersWarningCollector<ResolvedSignalAccount>({
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<ChannelPlugin<ResolvedSignalAccount>["setupWizard"]>;

View File

@ -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<typeof resolveSlackAccount>[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<ResolvedSlackAccount>({
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<ResolvedSlackAccount> = {
...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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
hint: "<channelId|user:ID|channel:ID>",
},
},
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<ResolvedSlackAccount> = {
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, {

View File

@ -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;

View File

@ -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([]);
});
});

View File

@ -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<ResolvedSynol
allowFrom.map((entry) => String(entry).trim().toLowerCase()).filter(Boolean),
});
const collectSynologyChatSecurityWarnings =
createConditionalWarningCollector<ResolvedSynologyChatAccount>(
(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<void> {
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,

View File

@ -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<ResolvedTelegramA
normalizeEntry: (raw) => 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<ResolvedTelegramAccount>({
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<ResolvedTelegramAccount, TelegramProbe> = {
...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<ResolvedTelegramAccount, TelegramProb
},
security: {
resolveDmPolicy: resolveTelegramDmPolicy,
collectWarnings: ({ account, cfg }) => {
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<ResolvedTelegramAccount, TelegramProb
}).catch(() => {});
},
},
directory: {
self: async () => null,
directory: createChannelDirectoryAdapter({
listPeers: async (params) => listTelegramDirectoryPeersFromConfig(params),
listGroups: async (params) => listTelegramDirectoryGroupsFromConfig(params),
},
}),
actions: telegramMessageActions,
setup: telegramSetupAdapter,
outbound: {

View File

@ -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,
});
}

View File

@ -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: {

View File

@ -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" },
]),
);
});
});

View File

@ -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<ResolvedWhatsAppAccount> = {
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),
},

View File

@ -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,
});
}

View File

@ -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<ResolvedWhatsAppAccount>({
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,
};
});
}

View File

@ -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<ResolvedZaloAccount>(
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<ResolvedZaloAccount> = {
id: "zalo",
meta,
@ -107,41 +144,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
},
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<ResolvedZaloAccount> = {
hint: "<chatId>",
},
},
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, ""),

View File

@ -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<ResolvedZalouserAccount> = {
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({

View File

@ -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([]);
});
});

View File

@ -0,0 +1,28 @@
import type { ChannelDirectoryAdapter } from "./types.adapters.js";
export const nullChannelDirectorySelf: NonNullable<ChannelDirectoryAdapter["self"]> = 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<ChannelDirectoryAdapter, "self"> & {
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,
});
}

View File

@ -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);
});
});

View File

@ -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<unknown>[];
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<InspectedAccount>(
params: DirectoryConfigParams & {
kind: "user" | "group";
inspectAccount: (
cfg: OpenClawConfig,
accountId?: string | null,
) => InspectedAccount | null | undefined;
resolveSources: (account: InspectedAccount) => Iterable<unknown>[];
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<ResolvedAccount>(
params: DirectoryConfigParams & {
kind: "user" | "group";
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount;
resolveSources: (account: ResolvedAccount) => Iterable<unknown>[];
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<ResolvedAccount>(
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<ResolvedAccount>(
params: DirectoryConfigParams & {
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount;
resolveGroups: (account: ResolvedAccount) => Record<string, unknown> | 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,
});
}

View File

@ -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<string, unknown>;
}>({
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<string, unknown>;
}>({
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,
}),
]);
});
});

View File

@ -7,6 +7,40 @@ import {
import type { GroupPolicy } from "../../config/types.base.js";
type GroupPolicyWarningCollector = (groupPolicy: GroupPolicy) => string[];
type AccountGroupPolicyWarningCollector<ResolvedAccount> = (params: {
account: ResolvedAccount;
cfg: OpenClawConfig;
}) => string[];
type ConfigGroupPolicyWarningCollector<Params extends { cfg: OpenClawConfig }> = (
params: Params,
) => string[];
type WarningCollector<Params> = (params: Params) => string[];
export function composeWarningCollectors<Params>(
...collectors: Array<WarningCollector<Params> | null | undefined>
): WarningCollector<Params> {
return (params) => collectors.flatMap((collector) => collector?.(params) ?? []);
}
export function projectWarningCollector<Params, Projected>(
project: (params: Params) => Projected,
collector: WarningCollector<Projected>,
): WarningCollector<Params> {
return (params) => collector(project(params));
}
export function createConditionalWarningCollector<Params>(
...collectors: Array<(params: Params) => string | string[] | null | undefined | false>
): WarningCollector<Params> {
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<ResolvedAccount>(
params: {
providerConfigPresent: (cfg: OpenClawConfig) => boolean;
resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined;
} & Omit<
Parameters<typeof collectAllowlistProviderRestrictSendersWarnings>[0],
"cfg" | "providerConfigPresent" | "configuredGroupPolicy"
>,
): AccountGroupPolicyWarningCollector<ResolvedAccount> {
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<ResolvedAccount>(
params: {
resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined;
defaultGroupPolicy?: GroupPolicy;
} & Omit<Parameters<typeof collectOpenGroupPolicyRestrictSendersWarnings>[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<Params> {
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<Params> {
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<ResolvedAccount>(params: {
providerConfigPresent: (cfg: OpenClawConfig) => boolean;
resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined;
buildOpenWarning: Parameters<typeof buildOpenGroupPolicyWarning>[0];
}): AccountGroupPolicyWarningCollector<ResolvedAccount> {
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<ResolvedAccount>(params: {
providerConfigPresent: (cfg: OpenClawConfig) => boolean;
resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined;
resolveRouteAllowlistConfigured: (account: ResolvedAccount) => boolean;
restrictSenders: Parameters<typeof buildOpenGroupPolicyRestrictSendersWarning>[0];
noRouteAllowlist: Parameters<typeof buildOpenGroupPolicyNoRouteAllowlistWarning>[0];
}): AccountGroupPolicyWarningCollector<ResolvedAccount> {
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<ResolvedAccount>(params: {
providerConfigPresent: (cfg: OpenClawConfig) => boolean;
resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined;
resolveRouteAllowlistConfigured: (account: ResolvedAccount) => boolean;
configureRouteAllowlist: Parameters<typeof buildOpenGroupPolicyConfigureRouteAllowlistWarning>[0];
missingRouteAllowlist: Parameters<typeof buildOpenGroupPolicyWarning>[0];
}): AccountGroupPolicyWarningCollector<ResolvedAccount> {
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,
}),
});
}

View File

@ -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");
});
});

View File

@ -0,0 +1,34 @@
import type { ChannelPairingAdapter } from "./types.adapters.js";
type PairingNotifyParams = Parameters<NonNullable<ChannelPairingAdapter["notifyApproval"]>>[0];
export function createPairingPrefixStripper(
prefixRe: RegExp,
map: (entry: string) => string = (entry) => entry,
): NonNullable<ChannelPairingAdapter["normalizeAllowEntry"]> {
return (entry) => map(entry.replace(prefixRe, ""));
}
export function createLoggedPairingApprovalNotifier(
format: string | ((params: PairingNotifyParams) => string),
log: (message: string) => void = console.log,
): NonNullable<ChannelPairingAdapter["notifyApproval"]> {
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> | void;
}): ChannelPairingAdapter {
return {
idLabel: params.idLabel,
normalizeAllowEntry: params.normalizeAllowEntry,
notifyApproval: async (ctx) => {
await params.notify({ ...ctx, message: params.message });
},
};
}

View File

@ -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");
});
});

View File

@ -0,0 +1,117 @@
import type { ChannelDirectoryAdapter, ChannelOutboundAdapter } from "./types.adapters.js";
type MaybePromise<T> = T | Promise<T>;
type DirectoryListMethod = "listPeersLive" | "listGroupsLive" | "listGroupMembers";
type OutboundMethod = "sendText" | "sendMedia" | "sendPoll";
type DirectoryListParams = Parameters<NonNullable<ChannelDirectoryAdapter["listPeersLive"]>>[0];
type DirectoryGroupMembersParams = Parameters<
NonNullable<ChannelDirectoryAdapter["listGroupMembers"]>
>[0];
type SendTextParams = Parameters<NonNullable<ChannelOutboundAdapter["sendText"]>>[0];
type SendMediaParams = Parameters<NonNullable<ChannelOutboundAdapter["sendMedia"]>>[0];
type SendPollParams = Parameters<NonNullable<ChannelOutboundAdapter["sendPoll"]>>[0];
async function resolveForwardedMethod<Runtime, Fn>(params: {
getRuntime: () => MaybePromise<Runtime>;
resolve: (runtime: Runtime) => Fn | null | undefined;
unavailableMessage?: string;
}): Promise<Fn> {
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<Runtime>(params: {
getRuntime: () => MaybePromise<Runtime>;
listPeersLive?: (runtime: Runtime) => ChannelDirectoryAdapter["listPeersLive"] | null | undefined;
listGroupsLive?: (
runtime: Runtime,
) => ChannelDirectoryAdapter["listGroupsLive"] | null | undefined;
listGroupMembers?: (
runtime: Runtime,
) => ChannelDirectoryAdapter["listGroupMembers"] | null | undefined;
}): Pick<ChannelDirectoryAdapter, DirectoryListMethod> {
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<Runtime>(params: {
getRuntime: () => MaybePromise<Runtime>;
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<ChannelOutboundAdapter, OutboundMethod> {
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,
};
}

View File

@ -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" }]);
});
});

View File

@ -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<TResult>(params: {
token?: string | null;
inputs: string[];
missingTokenNote: string;
resolveWithToken: (params: { token: string; inputs: string[] }) => Promise<TResult[]>;
mapResolved: (entry: TResult) => ChannelResolveResult;
}): Promise<ChannelResolveResult[]> {
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);
}

View File

@ -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<string, { users: string[] }> }) =>
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<string, { allowFrom?: string[] }> }
>;
}) => 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" },
},
});
});
});

View File

@ -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 | number>;
}) => string[];
type AllowlistAccountResolver<ResolvedAccount> = (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<string | number> | null | undefined,
): string[] {
return (entries ?? []).map(String).filter(Boolean);
}
/** Collect labeled allowlist overrides from a flat keyed record. */
export function collectAllowlistOverridesFromRecord<T>(params: {
record: Record<string, T | undefined> | null | undefined;
label: (key: string, value: T) => string;
resolveEntries: (value: T) => Array<string | number> | 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<Outer, Inner>(params: {
record: Record<string, Outer | undefined> | null | undefined;
outerLabel: (key: string, value: Outer) => string;
resolveOuterEntries: (value: Outer) => Array<string | number> | null | undefined;
resolveChildren: (value: Outer) => Record<string, Inner | undefined> | null | undefined;
innerLabel: (outerKey: string, innerKey: string, inner: Inner) => string;
resolveInnerEntries: (value: Inner) => Array<string | number> | 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<ResolvedAccount, Entry>(params: {
resolveRecord: (account: ResolvedAccount) => Record<string, Entry | undefined> | null | undefined;
label: (key: string, value: Entry) => string;
resolveEntries: (value: Entry) => Array<string | number> | 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<ResolvedAccount, Outer, Inner>(params: {
resolveRecord: (account: ResolvedAccount) => Record<string, Outer | undefined> | null | undefined;
outerLabel: (key: string, value: Outer) => string;
resolveOuterEntries: (value: Outer) => Array<string | number> | null | undefined;
resolveChildren: (value: Outer) => Record<string, Inner | undefined> | null | undefined;
innerLabel: (outerKey: string, innerKey: string, inner: Inner) => string;
resolveInnerEntries: (value: Inner) => Array<string | number> | 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<ResolvedAccount>(params: {
resolveAccount: (params: { cfg: OpenClawConfig; accountId?: string | null }) => ResolvedAccount;
resolveToken: (account: ResolvedAccount) => string | null | undefined;
resolveNames: (params: { token: string; entries: string[] }) => Promise<AllowlistNameResolution>;
}): NonNullable<ChannelAllowlistAdapter["resolveNames"]> {
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<string, unknown>,
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 | number>;
}) => string[];
normalize: AllowlistNormalizer;
resolvePaths: (scope: "dm" | "group") => AllowlistConfigPaths | null;
}): NonNullable<ChannelAllowlistAdapter["applyConfigEdit"]> {
return ({ cfg, parsedConfig, accountId, scope, action, entry }) => {
@ -219,3 +351,75 @@ export function buildAccountScopedAllowlistConfigEditor(params: {
});
};
}
function buildAccountAllowlistAdapter<ResolvedAccount>(params: {
channelId: ChannelId;
resolveAccount: AllowlistAccountResolver<ResolvedAccount>;
normalize: AllowlistNormalizer;
supportsScope: NonNullable<ChannelAllowlistAdapter["supportsScope"]>;
resolvePaths: (scope: "dm" | "group") => AllowlistConfigPaths | null;
readConfig: (
account: ResolvedAccount,
) => Awaited<ReturnType<NonNullable<ChannelAllowlistAdapter["readConfig"]>>>;
}): Pick<ChannelAllowlistAdapter, "supportsScope" | "readConfig" | "applyConfigEdit"> {
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<ResolvedAccount>(params: {
channelId: ChannelId;
resolveAccount: AllowlistAccountResolver<ResolvedAccount>;
normalize: AllowlistNormalizer;
resolveDmAllowFrom: (account: ResolvedAccount) => Array<string | number> | null | undefined;
resolveGroupAllowFrom: (account: ResolvedAccount) => Array<string | number> | null | undefined;
resolveDmPolicy?: (account: ResolvedAccount) => string | null | undefined;
resolveGroupPolicy?: (account: ResolvedAccount) => string | null | undefined;
resolveGroupOverrides?: (account: ResolvedAccount) => AllowlistGroupOverride[] | undefined;
}): Pick<ChannelAllowlistAdapter, "supportsScope" | "readConfig" | "applyConfigEdit"> {
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<ResolvedAccount>(params: {
channelId: ChannelId;
resolveAccount: AllowlistAccountResolver<ResolvedAccount>;
normalize: AllowlistNormalizer;
resolveDmAllowFrom: (account: ResolvedAccount) => Array<string | number> | null | undefined;
resolveGroupPolicy?: (account: ResolvedAccount) => string | null | undefined;
resolveGroupOverrides?: (account: ResolvedAccount) => AllowlistGroupOverride[] | undefined;
}): Pick<ChannelAllowlistAdapter, "supportsScope" | "readConfig" | "applyConfigEdit"> {
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),
}),
});
}

View File

@ -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 {

View File

@ -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";

View File

@ -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,

View File

@ -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");