refactor: deduplicate channel runtime helpers
This commit is contained in:
parent
3e02635df3
commit
27f655ed11
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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 }),
|
||||
|
||||
58
extensions/googlechat/src/channel.directory.test.ts
Normal file
58
extensions/googlechat/src/channel.directory.test.ts
Normal 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" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"]>;
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"]>;
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
62
extensions/whatsapp/src/channel.directory.test.ts
Normal file
62
extensions/whatsapp/src/channel.directory.test.ts
Normal 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" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -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, ""),
|
||||
|
||||
@ -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({
|
||||
|
||||
35
src/channels/plugins/directory-adapters.test.ts
Normal file
35
src/channels/plugins/directory-adapters.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
28
src/channels/plugins/directory-adapters.ts
Normal file
28
src/channels/plugins/directory-adapters.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
37
src/channels/plugins/pairing-adapters.test.ts
Normal file
37
src/channels/plugins/pairing-adapters.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
34
src/channels/plugins/pairing-adapters.ts
Normal file
34
src/channels/plugins/pairing-adapters.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
54
src/channels/plugins/runtime-forwarders.test.ts
Normal file
54
src/channels/plugins/runtime-forwarders.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
117
src/channels/plugins/runtime-forwarders.ts
Normal file
117
src/channels/plugins/runtime-forwarders.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
40
src/channels/plugins/target-resolvers.test.ts
Normal file
40
src/channels/plugins/target-resolvers.test.ts
Normal 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" }]);
|
||||
});
|
||||
});
|
||||
30
src/channels/plugins/target-resolvers.ts
Normal file
30
src/channels/plugins/target-resolvers.ts
Normal 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);
|
||||
}
|
||||
247
src/plugin-sdk/allowlist-config-edit.test.ts
Normal file
247
src/plugin-sdk/allowlist-config-edit.test.ts
Normal 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" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user