refactor: move channel delivery and ACP seams into plugins
This commit is contained in:
parent
d5b12f505c
commit
d163278e9c
@ -39,6 +39,7 @@ import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js
|
||||
import { normalizeMessageChannel } from "../../../src/utils/message-channel.js";
|
||||
import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js";
|
||||
import type { DiscordProbe } from "./probe.js";
|
||||
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
|
||||
import { getDiscordRuntime } from "./runtime.js";
|
||||
import { fetchChannelPermissionsDiscord } from "./send.js";
|
||||
import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js";
|
||||
@ -116,6 +117,84 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
function normalizeDiscordAcpConversationId(conversationId: string) {
|
||||
const normalized = conversationId.trim();
|
||||
return normalized ? { conversationId: normalized } : null;
|
||||
}
|
||||
|
||||
function matchDiscordAcpConversation(params: {
|
||||
bindingConversationId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}) {
|
||||
if (params.bindingConversationId === params.conversationId) {
|
||||
return { conversationId: params.conversationId, matchPriority: 2 };
|
||||
}
|
||||
if (
|
||||
params.parentConversationId &&
|
||||
params.parentConversationId !== params.conversationId &&
|
||||
params.bindingConversationId === params.parentConversationId
|
||||
) {
|
||||
return {
|
||||
conversationId: params.parentConversationId,
|
||||
matchPriority: 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDiscordExplicitTarget(raw: string) {
|
||||
try {
|
||||
const target = parseDiscordTarget(raw, { defaultKind: "channel" });
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: target.id,
|
||||
chatType: target.kind === "user" ? ("direct" as const) : ("channel" as const),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const discordConfigAccessors = createScopedAccountConfigAccessors({
|
||||
resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }),
|
||||
resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom,
|
||||
@ -177,6 +256,23 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
}),
|
||||
...discordConfigAccessors,
|
||||
},
|
||||
allowlist: {
|
||||
supportsScope: ({ scope }) => scope === "dm",
|
||||
readConfig: ({ cfg, accountId }) =>
|
||||
readDiscordAllowlistConfig(resolveDiscordAccount({ cfg, accountId })),
|
||||
resolveNames: async ({ cfg, accountId, entries }) =>
|
||||
await resolveDiscordAllowlistNames({ cfg, accountId, entries }),
|
||||
resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) =>
|
||||
scope === "dm"
|
||||
? {
|
||||
pathPrefix,
|
||||
writeTarget,
|
||||
readPaths: [["allowFrom"], ["dm", "allowFrom"]],
|
||||
writePath: ["allowFrom"],
|
||||
cleanupPaths: [["dm", "allowFrom"]],
|
||||
}
|
||||
: null,
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
return buildAccountScopedDmSecurityPolicy({
|
||||
@ -238,6 +334,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeDiscordMessagingTarget,
|
||||
parseExplicitTarget: ({ raw }) => parseDiscordExplicitTarget(raw),
|
||||
inferTargetChatType: ({ to }) => parseDiscordExplicitTarget(to)?.chatType,
|
||||
buildCrossContextComponents: buildDiscordCrossContextComponents,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeDiscordTargetId,
|
||||
@ -356,6 +454,12 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
silent: silent ?? undefined,
|
||||
}),
|
||||
},
|
||||
acpBindings: {
|
||||
normalizeConfiguredBindingTarget: ({ conversationId }) =>
|
||||
normalizeDiscordAcpConversationId(conversationId),
|
||||
matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) =>
|
||||
matchDiscordAcpConversation({ bindingConversationId, conversationId, parentConversationId }),
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
resolveDefaultFeishuAccountId,
|
||||
} from "./accounts.js";
|
||||
import { FeishuConfigSchema } from "./config-schema.js";
|
||||
import { parseFeishuConversationId } from "./conversation-id.js";
|
||||
import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js";
|
||||
import { resolveFeishuGroupToolPolicy } from "./policy.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
@ -95,6 +96,77 @@ function areAnyFeishuReactionActionsEnabled(cfg: ClawdbotConfig): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSupportedFeishuDirectConversationId(conversationId: string): boolean {
|
||||
const trimmed = conversationId.trim();
|
||||
if (!trimmed || trimmed.includes(":")) {
|
||||
return false;
|
||||
}
|
||||
if (trimmed.startsWith("oc_") || trimmed.startsWith("on_")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeFeishuAcpConversationId(conversationId: string) {
|
||||
const parsed = parseFeishuConversationId({ conversationId });
|
||||
if (
|
||||
!parsed ||
|
||||
(parsed.scope !== "group_topic" &&
|
||||
parsed.scope !== "group_topic_sender" &&
|
||||
!isSupportedFeishuDirectConversationId(parsed.canonicalConversationId))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
conversationId: parsed.canonicalConversationId,
|
||||
parentConversationId:
|
||||
parsed.scope === "group_topic" || parsed.scope === "group_topic_sender"
|
||||
? parsed.chatId
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function matchFeishuAcpConversation(params: {
|
||||
bindingConversationId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}) {
|
||||
const binding = normalizeFeishuAcpConversationId(params.bindingConversationId);
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
const incoming = parseFeishuConversationId({
|
||||
conversationId: params.conversationId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
});
|
||||
if (
|
||||
!incoming ||
|
||||
(incoming.scope !== "group_topic" &&
|
||||
incoming.scope !== "group_topic_sender" &&
|
||||
!isSupportedFeishuDirectConversationId(incoming.canonicalConversationId))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const matchesCanonicalConversation = binding.conversationId === incoming.canonicalConversationId;
|
||||
const matchesParentTopicForSenderScopedConversation =
|
||||
incoming.scope === "group_topic_sender" &&
|
||||
binding.parentConversationId === incoming.chatId &&
|
||||
binding.conversationId === `${incoming.chatId}:topic:${incoming.topicId}`;
|
||||
if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
conversationId: matchesParentTopicForSenderScopedConversation
|
||||
? binding.conversationId
|
||||
: incoming.canonicalConversationId,
|
||||
parentConversationId:
|
||||
incoming.scope === "group_topic" || incoming.scope === "group_topic_sender"
|
||||
? incoming.chatId
|
||||
: undefined,
|
||||
matchPriority: matchesCanonicalConversation ? 2 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
id: "feishu",
|
||||
meta: {
|
||||
@ -393,6 +465,12 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
});
|
||||
},
|
||||
},
|
||||
acpBindings: {
|
||||
normalizeConfiguredBindingTarget: ({ conversationId }) =>
|
||||
normalizeFeishuAcpConversationId(conversationId),
|
||||
matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) =>
|
||||
matchFeishuAcpConversation({ bindingConversationId, conversationId, parentConversationId }),
|
||||
},
|
||||
setup: feishuSetupAdapter,
|
||||
setupWizard: feishuSetupWizard,
|
||||
messaging: {
|
||||
|
||||
@ -124,6 +124,24 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom),
|
||||
resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }),
|
||||
},
|
||||
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,
|
||||
};
|
||||
},
|
||||
resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({
|
||||
pathPrefix,
|
||||
writeTarget,
|
||||
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
|
||||
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
|
||||
}),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
return buildAccountScopedDmSecurityPolicy({
|
||||
|
||||
@ -26,7 +26,10 @@ import {
|
||||
type ChannelPlugin,
|
||||
type ResolvedSignalAccount,
|
||||
} from "openclaw/plugin-sdk/signal";
|
||||
import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js";
|
||||
import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
|
||||
import { markdownToSignalTextChunks } from "./format.js";
|
||||
import type { SignalProbe } from "./probe.js";
|
||||
import { getSignalRuntime } from "./runtime.js";
|
||||
import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js";
|
||||
@ -66,12 +69,8 @@ const signalConfigAccessors = createScopedAccountConfigAccessors({
|
||||
|
||||
type SignalSendFn = ReturnType<typeof getSignalRuntime>["channel"]["signal"]["sendMessageSignal"];
|
||||
|
||||
async function sendSignalOutbound(params: {
|
||||
function resolveSignalSendContext(params: {
|
||||
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
||||
to: string;
|
||||
text: string;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
accountId?: string;
|
||||
deps?: { [channelId: string]: unknown };
|
||||
}) {
|
||||
@ -84,6 +83,19 @@ async function sendSignalOutbound(params: {
|
||||
cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.channels?.signal?.mediaMaxMb,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
return { send, maxBytes };
|
||||
}
|
||||
|
||||
async function sendSignalOutbound(params: {
|
||||
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
||||
to: string;
|
||||
text: string;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
accountId?: string;
|
||||
deps?: { [channelId: string]: unknown };
|
||||
}) {
|
||||
const { send, maxBytes } = resolveSignalSendContext(params);
|
||||
return await send(params.to, params.text, {
|
||||
cfg: params.cfg,
|
||||
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
|
||||
@ -93,6 +105,120 @@ async function sendSignalOutbound(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function inferSignalTargetChatType(rawTo: string) {
|
||||
let to = rawTo.trim();
|
||||
if (!to) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^signal:/i.test(to)) {
|
||||
to = to.replace(/^signal:/i, "").trim();
|
||||
}
|
||||
if (!to) {
|
||||
return undefined;
|
||||
}
|
||||
const lower = to.toLowerCase();
|
||||
if (lower.startsWith("group:")) {
|
||||
return "group" as const;
|
||||
}
|
||||
if (lower.startsWith("username:") || lower.startsWith("u:")) {
|
||||
return "direct" as const;
|
||||
}
|
||||
return "direct" as const;
|
||||
}
|
||||
|
||||
function parseSignalExplicitTarget(raw: string) {
|
||||
const normalized = normalizeSignalMessagingTarget(raw);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: normalized,
|
||||
chatType: inferSignalTargetChatType(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
async function sendFormattedSignalText(ctx: {
|
||||
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
||||
to: string;
|
||||
text: string;
|
||||
accountId?: string | null;
|
||||
deps?: { [channelId: string]: unknown };
|
||||
abortSignal?: AbortSignal;
|
||||
}) {
|
||||
const { send, maxBytes } = resolveSignalSendContext({
|
||||
cfg: ctx.cfg,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
deps: ctx.deps,
|
||||
});
|
||||
const limit = resolveTextChunkLimit(ctx.cfg, "signal", ctx.accountId ?? undefined, {
|
||||
fallbackLimit: 4000,
|
||||
});
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg: ctx.cfg,
|
||||
channel: "signal",
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
let chunks =
|
||||
limit === undefined
|
||||
? markdownToSignalTextChunks(ctx.text, Number.POSITIVE_INFINITY, { tableMode })
|
||||
: markdownToSignalTextChunks(ctx.text, limit, { tableMode });
|
||||
if (chunks.length === 0 && ctx.text) {
|
||||
chunks = [{ text: ctx.text, styles: [] }];
|
||||
}
|
||||
const results = [];
|
||||
for (const chunk of chunks) {
|
||||
ctx.abortSignal?.throwIfAborted();
|
||||
const result = await send(ctx.to, chunk.text, {
|
||||
cfg: ctx.cfg,
|
||||
maxBytes,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
textMode: "plain",
|
||||
textStyles: chunk.styles,
|
||||
});
|
||||
results.push({ channel: "signal" as const, ...result });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function sendFormattedSignalMedia(ctx: {
|
||||
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
||||
to: string;
|
||||
text: string;
|
||||
mediaUrl: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
accountId?: string | null;
|
||||
deps?: { [channelId: string]: unknown };
|
||||
abortSignal?: AbortSignal;
|
||||
}) {
|
||||
ctx.abortSignal?.throwIfAborted();
|
||||
const { send, maxBytes } = resolveSignalSendContext({
|
||||
cfg: ctx.cfg,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
deps: ctx.deps,
|
||||
});
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg: ctx.cfg,
|
||||
channel: "signal",
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
const formatted = markdownToSignalTextChunks(ctx.text, Number.POSITIVE_INFINITY, {
|
||||
tableMode,
|
||||
})[0] ?? {
|
||||
text: ctx.text,
|
||||
styles: [],
|
||||
};
|
||||
const result = await send(ctx.to, formatted.text, {
|
||||
cfg: ctx.cfg,
|
||||
mediaUrl: ctx.mediaUrl,
|
||||
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||
maxBytes,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
textMode: "plain",
|
||||
textStyles: formatted.styles,
|
||||
});
|
||||
return { channel: "signal" as const, ...result };
|
||||
}
|
||||
|
||||
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
id: "signal",
|
||||
meta: {
|
||||
@ -146,6 +272,24 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
}),
|
||||
...signalConfigAccessors,
|
||||
},
|
||||
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,
|
||||
};
|
||||
},
|
||||
resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({
|
||||
pathPrefix,
|
||||
writeTarget,
|
||||
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
|
||||
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
|
||||
}),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
return buildAccountScopedDmSecurityPolicy({
|
||||
@ -174,6 +318,8 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeSignalMessagingTarget,
|
||||
parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw),
|
||||
inferTargetChatType: ({ to }) => inferSignalTargetChatType(to),
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeSignalTargetId,
|
||||
hint: "<E.164|uuid:ID|group:ID|signal:group:ID|signal:+E.164>",
|
||||
@ -185,6 +331,35 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit),
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 4000,
|
||||
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) =>
|
||||
await sendFormattedSignalText({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
accountId,
|
||||
deps,
|
||||
abortSignal,
|
||||
}),
|
||||
sendFormattedMedia: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
accountId,
|
||||
deps,
|
||||
abortSignal,
|
||||
}) =>
|
||||
await sendFormattedSignalMedia({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
accountId,
|
||||
deps,
|
||||
abortSignal,
|
||||
}),
|
||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||
const result = await sendSignalOutbound({
|
||||
cfg,
|
||||
|
||||
@ -38,6 +38,7 @@ import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js
|
||||
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
|
||||
import { parseSlackBlocksInput } from "./blocks-input.js";
|
||||
import type { SlackProbe } from "./probe.js";
|
||||
import { resolveSlackUserAllowlist } from "./resolve-users.js";
|
||||
import { getSlackRuntime } from "./runtime.js";
|
||||
import { fetchSlackScopes } from "./scopes.js";
|
||||
import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js";
|
||||
@ -129,6 +130,17 @@ function resolveSlackAutoThreadId(params: {
|
||||
return context.currentThreadTs;
|
||||
}
|
||||
|
||||
function parseSlackExplicitTarget(raw: string) {
|
||||
const target = parseSlackTarget(raw, { defaultKind: "channel" });
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: target.id,
|
||||
chatType: target.kind === "user" ? ("direct" as const) : ("channel" as const),
|
||||
};
|
||||
}
|
||||
|
||||
function formatSlackScopeDiagnostic(params: {
|
||||
tokenType: "bot" | "user";
|
||||
result: Awaited<ReturnType<typeof fetchSlackScopes>>;
|
||||
@ -144,6 +156,32 @@ 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[] }>,
|
||||
};
|
||||
}
|
||||
|
||||
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 slackConfigAccessors = createScopedAccountConfigAccessors({
|
||||
resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }),
|
||||
resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom,
|
||||
@ -235,6 +273,23 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
}),
|
||||
...slackConfigAccessors,
|
||||
},
|
||||
allowlist: {
|
||||
supportsScope: ({ scope }) => scope === "dm",
|
||||
readConfig: ({ cfg, accountId }) =>
|
||||
readSlackAllowlistConfig(resolveSlackAccount({ cfg, accountId })),
|
||||
resolveNames: async ({ cfg, accountId, entries }) =>
|
||||
await resolveSlackAllowlistNames({ cfg, accountId, entries }),
|
||||
resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) =>
|
||||
scope === "dm"
|
||||
? {
|
||||
pathPrefix,
|
||||
writeTarget,
|
||||
readPaths: [["allowFrom"], ["dm", "allowFrom"]],
|
||||
writePath: ["allowFrom"],
|
||||
cleanupPaths: [["dm", "allowFrom"]],
|
||||
}
|
||||
: null,
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
return buildAccountScopedDmSecurityPolicy({
|
||||
@ -301,6 +356,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeSlackMessagingTarget,
|
||||
parseExplicitTarget: ({ raw }) => parseSlackExplicitTarget(raw),
|
||||
inferTargetChatType: ({ to }) => parseSlackExplicitTarget(to)?.chatType,
|
||||
enableInteractiveReplies: ({ cfg, accountId }) =>
|
||||
isSlackInteractiveRepliesEnabled({ cfg, accountId }),
|
||||
hasStructuredReplyPayload: ({ payload }) => {
|
||||
|
||||
@ -37,6 +37,7 @@ import {
|
||||
type ResolvedTelegramAccount,
|
||||
type TelegramProbe,
|
||||
} from "openclaw/plugin-sdk/telegram";
|
||||
import { parseTelegramTopicConversation } from "../../../src/acp/conversation-id.js";
|
||||
import { resolveExecApprovalCommandDisplay } from "../../../src/infra/exec-approval-command-display.js";
|
||||
import { buildExecApprovalPendingReplyPayload } from "../../../src/infra/exec-approval-reply.js";
|
||||
import {
|
||||
@ -166,6 +167,52 @@ function resolveTelegramAutoThreadId(params: {
|
||||
return context.currentThreadTs;
|
||||
}
|
||||
|
||||
function normalizeTelegramAcpConversationId(conversationId: string) {
|
||||
const parsed = parseTelegramTopicConversation({ conversationId });
|
||||
if (!parsed || !parsed.chatId.startsWith("-")) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
conversationId: parsed.canonicalConversationId,
|
||||
parentConversationId: parsed.chatId,
|
||||
};
|
||||
}
|
||||
|
||||
function matchTelegramAcpConversation(params: {
|
||||
bindingConversationId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}) {
|
||||
const binding = normalizeTelegramAcpConversationId(params.bindingConversationId);
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
const incoming = parseTelegramTopicConversation({
|
||||
conversationId: params.conversationId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
});
|
||||
if (!incoming || !incoming.chatId.startsWith("-")) {
|
||||
return null;
|
||||
}
|
||||
if (binding.conversationId !== incoming.canonicalConversationId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
conversationId: incoming.canonicalConversationId,
|
||||
parentConversationId: incoming.chatId,
|
||||
matchPriority: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTelegramExplicitTarget(raw: string) {
|
||||
const target = parseTelegramTarget(raw);
|
||||
return {
|
||||
to: target.chatId,
|
||||
threadId: target.messageThreadId,
|
||||
chatType: target.chatType === "unknown" ? undefined : target.chatType,
|
||||
};
|
||||
}
|
||||
|
||||
function hasTelegramExecApprovalDmRoute(cfg: OpenClawConfig): boolean {
|
||||
return listTelegramAccountIds(cfg).some((accountId) => {
|
||||
if (!isTelegramExecApprovalClientEnabled({ cfg, accountId })) {
|
||||
@ -217,6 +264,29 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProbe> = {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
@ -284,6 +354,23 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
}),
|
||||
...telegramConfigAccessors,
|
||||
},
|
||||
allowlist: {
|
||||
supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all",
|
||||
readConfig: ({ cfg, accountId }) =>
|
||||
readTelegramAllowlistConfig(resolveTelegramAccount({ cfg, accountId })),
|
||||
resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({
|
||||
pathPrefix,
|
||||
writeTarget,
|
||||
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
|
||||
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
|
||||
}),
|
||||
},
|
||||
acpBindings: {
|
||||
normalizeConfiguredBindingTarget: ({ conversationId }) =>
|
||||
normalizeTelegramAcpConversationId(conversationId),
|
||||
matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) =>
|
||||
matchTelegramAcpConversation({ bindingConversationId, conversationId, parentConversationId }),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: resolveTelegramDmPolicy,
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
@ -325,6 +412,8 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeTelegramMessagingTarget,
|
||||
parseExplicitTarget: ({ raw }) => parseTelegramExplicitTarget(raw),
|
||||
inferTargetChatType: ({ to }) => parseTelegramExplicitTarget(to).chatType,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeTelegramTargetId,
|
||||
hint: "<chatId>",
|
||||
@ -423,6 +512,9 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
pollMaxOptions: 10,
|
||||
shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData),
|
||||
resolveEffectiveTextChunkLimit: ({ fallbackLimit }) =>
|
||||
typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096,
|
||||
sendPayload: async ({
|
||||
cfg,
|
||||
to,
|
||||
|
||||
@ -102,6 +102,9 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
chunker: markdownToTelegramHtmlChunks,
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData),
|
||||
resolveEffectiveTextChunkLimit: ({ fallbackLimit }) =>
|
||||
typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096,
|
||||
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => {
|
||||
const { send, baseOpts } = resolveTelegramSendContext({
|
||||
cfg,
|
||||
|
||||
@ -24,6 +24,7 @@ import {
|
||||
type ChannelMessageActionName,
|
||||
type ChannelPlugin,
|
||||
} from "openclaw/plugin-sdk/whatsapp";
|
||||
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../../src/whatsapp/normalize.js";
|
||||
// WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/)
|
||||
import {
|
||||
listWhatsAppAccountIds,
|
||||
@ -42,6 +43,21 @@ async function loadWhatsAppChannelRuntime() {
|
||||
return await import("./channel.runtime.js");
|
||||
}
|
||||
|
||||
function normalizeWhatsAppPayloadText(text: string | undefined): string {
|
||||
return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, "");
|
||||
}
|
||||
|
||||
function parseWhatsAppExplicitTarget(raw: string) {
|
||||
const normalized = normalizeWhatsAppTarget(raw);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: normalized,
|
||||
chatType: isWhatsAppGroupJid(normalized) ? ("group" as const) : ("direct" as const),
|
||||
};
|
||||
}
|
||||
|
||||
const whatsappSetupWizardProxy = {
|
||||
channel: "whatsapp",
|
||||
status: {
|
||||
@ -168,6 +184,24 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom),
|
||||
resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }),
|
||||
},
|
||||
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,
|
||||
};
|
||||
},
|
||||
resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({
|
||||
pathPrefix,
|
||||
writeTarget,
|
||||
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
|
||||
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
|
||||
}),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
return buildAccountScopedDmSecurityPolicy({
|
||||
@ -224,6 +258,8 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeWhatsAppMessagingTarget,
|
||||
parseExplicitTarget: ({ raw }) => parseWhatsAppExplicitTarget(raw),
|
||||
inferTargetChatType: ({ to }) => parseWhatsAppExplicitTarget(to)?.chatType,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeWhatsAppTargetId,
|
||||
hint: "<E.164|group JID>",
|
||||
@ -288,16 +324,22 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
);
|
||||
},
|
||||
},
|
||||
outbound: createWhatsAppOutboundBase({
|
||||
chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit),
|
||||
sendMessageWhatsApp: async (...args) =>
|
||||
await getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp(...args),
|
||||
sendPollWhatsApp: async (...args) =>
|
||||
await getWhatsAppRuntime().channel.whatsapp.sendPollWhatsApp(...args),
|
||||
shouldLogVerbose: () => getWhatsAppRuntime().logging.shouldLogVerbose(),
|
||||
resolveTarget: ({ to, allowFrom, mode }) =>
|
||||
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
|
||||
}),
|
||||
outbound: {
|
||||
...createWhatsAppOutboundBase({
|
||||
chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit),
|
||||
sendMessageWhatsApp: async (...args) =>
|
||||
await getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp(...args),
|
||||
sendPollWhatsApp: async (...args) =>
|
||||
await getWhatsAppRuntime().channel.whatsapp.sendPollWhatsApp(...args),
|
||||
shouldLogVerbose: () => getWhatsAppRuntime().logging.shouldLogVerbose(),
|
||||
resolveTarget: ({ to, allowFrom, mode }) =>
|
||||
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
|
||||
}),
|
||||
normalizePayload: ({ payload }) => ({
|
||||
...payload,
|
||||
text: normalizeWhatsAppPayloadText(payload.text),
|
||||
}),
|
||||
},
|
||||
auth: {
|
||||
login: async ({ cfg, accountId, runtime, verbose }) => {
|
||||
const resolvedAccountId = accountId?.trim() || resolveDefaultWhatsAppAccountId(cfg);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { parseFeishuConversationId } from "../../extensions/feishu/src/conversation-id.js";
|
||||
import { getChannelPlugin } from "../channels/plugins/index.js";
|
||||
import { listAcpBindings } from "../config/bindings.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { AgentAcpBinding } from "../config/types.js";
|
||||
@ -8,7 +8,6 @@ import {
|
||||
normalizeAccountId,
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { parseTelegramTopicConversation } from "./conversation-id.js";
|
||||
import {
|
||||
buildConfiguredAcpSessionKey,
|
||||
normalizeBindingConfig,
|
||||
@ -22,21 +21,11 @@ import {
|
||||
|
||||
function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null {
|
||||
const normalized = (value ?? "").trim().toLowerCase();
|
||||
if (normalized === "discord" || normalized === "telegram" || normalized === "feishu") {
|
||||
return normalized;
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSupportedFeishuDirectConversationId(conversationId: string): boolean {
|
||||
const trimmed = conversationId.trim();
|
||||
if (!trimmed || trimmed.includes(":")) {
|
||||
return false;
|
||||
}
|
||||
if (trimmed.startsWith("oc_") || trimmed.startsWith("on_")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
const plugin = getChannelPlugin(normalized);
|
||||
return plugin?.acpBindings ? plugin.id : null;
|
||||
}
|
||||
|
||||
function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 {
|
||||
@ -71,10 +60,9 @@ function parseConfiguredBindingSessionKey(params: {
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
const accountId = normalizeAccountId(tokens[3]);
|
||||
return {
|
||||
channel,
|
||||
accountId,
|
||||
accountId: normalizeAccountId(tokens[3]),
|
||||
};
|
||||
}
|
||||
|
||||
@ -231,6 +219,12 @@ export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
|
||||
if (!parsedSessionKey) {
|
||||
return null;
|
||||
}
|
||||
const plugin = getChannelPlugin(parsedSessionKey.channel);
|
||||
const acpBindings = plugin?.acpBindings;
|
||||
if (!acpBindings?.normalizeConfiguredBindingTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let wildcardMatch: ConfiguredAcpBindingSpec | null = null;
|
||||
for (const binding of listAcpBindings(params.cfg)) {
|
||||
const channel = normalizeBindingChannel(binding.match.channel);
|
||||
@ -248,81 +242,29 @@ export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
|
||||
if (!targetConversationId) {
|
||||
continue;
|
||||
}
|
||||
if (channel === "discord") {
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: "discord",
|
||||
accountId: parsedSessionKey.accountId,
|
||||
conversationId: targetConversationId,
|
||||
binding,
|
||||
});
|
||||
if (buildConfiguredAcpSessionKey(spec) === sessionKey) {
|
||||
if (accountMatchPriority === 2) {
|
||||
return spec;
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
wildcardMatch = spec;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (channel === "feishu") {
|
||||
const targetParsed = parseFeishuConversationId({
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
if (
|
||||
!targetParsed ||
|
||||
(targetParsed.scope !== "group_topic" &&
|
||||
targetParsed.scope !== "group_topic_sender" &&
|
||||
!isSupportedFeishuDirectConversationId(targetParsed.canonicalConversationId))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: "feishu",
|
||||
accountId: parsedSessionKey.accountId,
|
||||
conversationId: targetParsed.canonicalConversationId,
|
||||
// Session-key recovery deliberately collapses sender-scoped topic bindings onto the
|
||||
// canonical topic conversation id so `group_topic` and `group_topic_sender` reuse
|
||||
// the same configured ACP session identity.
|
||||
parentConversationId:
|
||||
targetParsed.scope === "group_topic" || targetParsed.scope === "group_topic_sender"
|
||||
? targetParsed.chatId
|
||||
: undefined,
|
||||
binding,
|
||||
});
|
||||
if (buildConfiguredAcpSessionKey(spec) === sessionKey) {
|
||||
if (accountMatchPriority === 2) {
|
||||
return spec;
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
wildcardMatch = spec;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const parsedTopic = parseTelegramTopicConversation({
|
||||
const target = acpBindings.normalizeConfiguredBindingTarget({
|
||||
binding,
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
if (!parsedTopic || !parsedTopic.chatId.startsWith("-")) {
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: "telegram",
|
||||
channel,
|
||||
accountId: parsedSessionKey.accountId,
|
||||
conversationId: parsedTopic.canonicalConversationId,
|
||||
parentConversationId: parsedTopic.chatId,
|
||||
conversationId: target.conversationId,
|
||||
parentConversationId: target.parentConversationId,
|
||||
binding,
|
||||
});
|
||||
if (buildConfiguredAcpSessionKey(spec) === sessionKey) {
|
||||
if (accountMatchPriority === 2) {
|
||||
return spec;
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
wildcardMatch = spec;
|
||||
}
|
||||
if (buildConfiguredAcpSessionKey(spec) !== sessionKey) {
|
||||
continue;
|
||||
}
|
||||
if (accountMatchPriority === 2) {
|
||||
return spec;
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
wildcardMatch = spec;
|
||||
}
|
||||
}
|
||||
return wildcardMatch;
|
||||
@ -335,136 +277,36 @@ export function resolveConfiguredAcpBindingRecord(params: {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}): ResolvedConfiguredAcpBinding | null {
|
||||
const channel = params.channel.trim().toLowerCase();
|
||||
const channel = normalizeBindingChannel(params.channel);
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const conversationId = params.conversationId.trim();
|
||||
const parentConversationId = params.parentConversationId?.trim() || undefined;
|
||||
if (!conversationId) {
|
||||
if (!channel || !conversationId) {
|
||||
return null;
|
||||
}
|
||||
const plugin = getChannelPlugin(channel);
|
||||
const acpBindings = plugin?.acpBindings;
|
||||
if (!acpBindings?.matchConfiguredBinding) {
|
||||
return null;
|
||||
}
|
||||
const matchConfiguredBinding = acpBindings.matchConfiguredBinding;
|
||||
|
||||
if (channel === "discord") {
|
||||
const bindings = listAcpBindings(params.cfg);
|
||||
const resolveDiscordBindingForConversation = (targetConversationId: string) =>
|
||||
resolveConfiguredBindingRecord({
|
||||
cfg: params.cfg,
|
||||
bindings,
|
||||
channel: "discord",
|
||||
accountId,
|
||||
selectConversation: (binding) => {
|
||||
const bindingConversationId = resolveBindingConversationId(binding);
|
||||
if (!bindingConversationId || bindingConversationId !== targetConversationId) {
|
||||
return null;
|
||||
}
|
||||
return { conversationId: targetConversationId };
|
||||
},
|
||||
});
|
||||
|
||||
const directMatch = resolveDiscordBindingForConversation(conversationId);
|
||||
if (directMatch) {
|
||||
return directMatch;
|
||||
}
|
||||
if (parentConversationId && parentConversationId !== conversationId) {
|
||||
const inheritedMatch = resolveDiscordBindingForConversation(parentConversationId);
|
||||
if (inheritedMatch) {
|
||||
return inheritedMatch;
|
||||
return resolveConfiguredBindingRecord({
|
||||
cfg: params.cfg,
|
||||
bindings: listAcpBindings(params.cfg),
|
||||
channel,
|
||||
accountId,
|
||||
selectConversation: (binding) => {
|
||||
const bindingConversationId = resolveBindingConversationId(binding);
|
||||
if (!bindingConversationId) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (channel === "telegram") {
|
||||
const parsed = parseTelegramTopicConversation({
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
});
|
||||
if (!parsed || !parsed.chatId.startsWith("-")) {
|
||||
return null;
|
||||
}
|
||||
return resolveConfiguredBindingRecord({
|
||||
cfg: params.cfg,
|
||||
bindings: listAcpBindings(params.cfg),
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
selectConversation: (binding) => {
|
||||
const targetConversationId = resolveBindingConversationId(binding);
|
||||
if (!targetConversationId) {
|
||||
return null;
|
||||
}
|
||||
const targetParsed = parseTelegramTopicConversation({
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
if (!targetParsed || !targetParsed.chatId.startsWith("-")) {
|
||||
return null;
|
||||
}
|
||||
if (targetParsed.canonicalConversationId !== parsed.canonicalConversationId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
conversationId: parsed.canonicalConversationId,
|
||||
parentConversationId: parsed.chatId,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (channel === "feishu") {
|
||||
const parsed = parseFeishuConversationId({
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
});
|
||||
if (
|
||||
!parsed ||
|
||||
(parsed.scope !== "group_topic" &&
|
||||
parsed.scope !== "group_topic_sender" &&
|
||||
!isSupportedFeishuDirectConversationId(parsed.canonicalConversationId))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return resolveConfiguredBindingRecord({
|
||||
cfg: params.cfg,
|
||||
bindings: listAcpBindings(params.cfg),
|
||||
channel: "feishu",
|
||||
accountId,
|
||||
selectConversation: (binding) => {
|
||||
const targetConversationId = resolveBindingConversationId(binding);
|
||||
if (!targetConversationId) {
|
||||
return null;
|
||||
}
|
||||
const targetParsed = parseFeishuConversationId({
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
if (
|
||||
!targetParsed ||
|
||||
(targetParsed.scope !== "group_topic" &&
|
||||
targetParsed.scope !== "group_topic_sender" &&
|
||||
!isSupportedFeishuDirectConversationId(targetParsed.canonicalConversationId))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const matchesCanonicalConversation =
|
||||
targetParsed.canonicalConversationId === parsed.canonicalConversationId;
|
||||
const matchesParentTopicForSenderScopedConversation =
|
||||
parsed.scope === "group_topic_sender" &&
|
||||
targetParsed.scope === "group_topic" &&
|
||||
parsed.chatId === targetParsed.chatId &&
|
||||
parsed.topicId === targetParsed.topicId;
|
||||
if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
conversationId: matchesParentTopicForSenderScopedConversation
|
||||
? targetParsed.canonicalConversationId
|
||||
: parsed.canonicalConversationId,
|
||||
parentConversationId:
|
||||
parsed.scope === "group_topic" || parsed.scope === "group_topic_sender"
|
||||
? parsed.chatId
|
||||
: undefined,
|
||||
matchPriority: matchesCanonicalConversation ? 2 : 1,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
return matchConfiguredBinding({
|
||||
binding,
|
||||
bindingConversationId,
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { discordPlugin } from "../../extensions/discord/src/channel.js";
|
||||
import { feishuPlugin } from "../../extensions/feishu/src/channel.js";
|
||||
import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
const managerMocks = vi.hoisted(() => ({
|
||||
resolveSession: vi.fn(),
|
||||
closeSession: vi.fn(),
|
||||
@ -162,6 +167,13 @@ function mockReadySession(params: { spec: BindingSpec; cwd: string }) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "discord", plugin: discordPlugin, source: "test" },
|
||||
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
|
||||
{ pluginId: "feishu", plugin: feishuPlugin, source: "test" },
|
||||
]),
|
||||
);
|
||||
managerMocks.resolveSession.mockReset();
|
||||
managerMocks.closeSession.mockReset().mockResolvedValue({
|
||||
runtimeClosed: true,
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js";
|
||||
import { sanitizeAgentId } from "../routing/session-key.js";
|
||||
import type { AcpRuntimeSessionMode } from "./runtime/types.js";
|
||||
|
||||
export type ConfiguredAcpBindingChannel = "discord" | "telegram" | "feishu";
|
||||
export type ConfiguredAcpBindingChannel = ChannelId;
|
||||
|
||||
export type ConfiguredAcpBindingSpec = {
|
||||
channel: ConfiguredAcpBindingChannel;
|
||||
|
||||
@ -1,13 +1,4 @@
|
||||
import { resolveDiscordAccount } from "../../../extensions/discord/src/accounts.js";
|
||||
import { resolveDiscordUserAllowlist } from "../../../extensions/discord/src/resolve-users.js";
|
||||
import { resolveIMessageAccount } from "../../../extensions/imessage/src/accounts.js";
|
||||
import { resolveSignalAccount } from "../../../extensions/signal/src/accounts.js";
|
||||
import { resolveSlackAccount } from "../../../extensions/slack/src/accounts.js";
|
||||
import { resolveSlackUserAllowlist } from "../../../extensions/slack/src/resolve-users.js";
|
||||
import { resolveTelegramAccount } from "../../../extensions/telegram/src/accounts.js";
|
||||
import { resolveWhatsAppAccount } from "../../../extensions/whatsapp/src/accounts.js";
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import { resolveExplicitConfigWriteTarget } from "../../channels/plugins/config-writes.js";
|
||||
import { getChannelPlugin } from "../../channels/plugins/index.js";
|
||||
import { listPairingChannels } from "../../channels/plugins/pairing.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import { normalizeChannelId } from "../../channels/registry.js";
|
||||
@ -159,9 +150,9 @@ function normalizeAllowFrom(params: {
|
||||
accountId?: string | null;
|
||||
values: Array<string | number>;
|
||||
}): string[] {
|
||||
const dock = getChannelDock(params.channelId);
|
||||
if (dock?.config?.formatAllowFrom) {
|
||||
return dock.config.formatAllowFrom({
|
||||
const plugin = getChannelPlugin(params.channelId);
|
||||
if (plugin?.config.formatAllowFrom) {
|
||||
return plugin.config.formatAllowFrom({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
allowFrom: params.values,
|
||||
@ -182,22 +173,6 @@ function formatEntryList(entries: string[], resolved?: Map<string, string>): str
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function extractConfigAllowlist(account: {
|
||||
config?: {
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
dmPolicy?: string;
|
||||
groupPolicy?: string;
|
||||
};
|
||||
}) {
|
||||
return {
|
||||
dmAllowFrom: (account.config?.allowFrom ?? []).map(String),
|
||||
groupAllowFrom: (account.config?.groupAllowFrom ?? []).map(String),
|
||||
dmPolicy: account.config?.dmPolicy,
|
||||
groupPolicy: account.config?.groupPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
async function updatePairingStoreAllowlist(params: {
|
||||
action: "add" | "remove";
|
||||
channelId: ChannelId;
|
||||
@ -236,7 +211,7 @@ function resolveAccountTarget(
|
||||
target: channel,
|
||||
pathPrefix: `channels.${channelId}`,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
writeTarget: resolveExplicitConfigWriteTarget({ channelId }),
|
||||
writeTarget: { kind: "channel", scope: { channelId } } as const,
|
||||
};
|
||||
}
|
||||
const hasAccounts = Boolean(channel.accounts && typeof channel.accounts === "object");
|
||||
@ -246,7 +221,7 @@ function resolveAccountTarget(
|
||||
target: channel,
|
||||
pathPrefix: `channels.${channelId}`,
|
||||
accountId: normalizedAccountId,
|
||||
writeTarget: resolveExplicitConfigWriteTarget({ channelId }),
|
||||
writeTarget: { kind: "channel", scope: { channelId } } as const,
|
||||
};
|
||||
}
|
||||
const accounts = (channel.accounts ??= {}) as Record<string, unknown>;
|
||||
@ -261,10 +236,10 @@ function resolveAccountTarget(
|
||||
target: account,
|
||||
pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`,
|
||||
accountId: normalizedAccountId,
|
||||
writeTarget: resolveExplicitConfigWriteTarget({
|
||||
channelId,
|
||||
accountId: normalizedAccountId,
|
||||
}),
|
||||
writeTarget: {
|
||||
kind: "account",
|
||||
scope: { channelId, accountId: normalizedAccountId },
|
||||
} as const,
|
||||
};
|
||||
}
|
||||
|
||||
@ -321,37 +296,6 @@ function deleteNestedValue(root: Record<string, unknown>, path: string[]) {
|
||||
delete (parent as Record<string, unknown>)[path[path.length - 1]];
|
||||
}
|
||||
|
||||
function resolveChannelAllowFromPaths(
|
||||
channelId: ChannelId,
|
||||
scope: AllowlistScope,
|
||||
): string[] | null {
|
||||
const supportsGroupAllowlist =
|
||||
channelId === "telegram" ||
|
||||
channelId === "whatsapp" ||
|
||||
channelId === "signal" ||
|
||||
channelId === "imessage";
|
||||
if (scope === "all") {
|
||||
return null;
|
||||
}
|
||||
if (scope === "dm") {
|
||||
if (channelId === "slack" || channelId === "discord") {
|
||||
// Canonical DM allowlist location for Slack/Discord. Legacy: dm.allowFrom.
|
||||
return ["allowFrom"];
|
||||
}
|
||||
if (supportsGroupAllowlist) {
|
||||
return ["allowFrom"];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (scope === "group") {
|
||||
if (supportsGroupAllowlist) {
|
||||
return ["groupAllowFrom"];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mapResolvedAllowlistNames(entries: ResolvedAllowlistName[]): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
@ -362,32 +306,35 @@ function mapResolvedAllowlistNames(entries: ResolvedAllowlistName[]): Map<string
|
||||
return map;
|
||||
}
|
||||
|
||||
async function resolveSlackNames(params: {
|
||||
async function resolveAllowlistNames(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channelId: ChannelId;
|
||||
accountId?: string | null;
|
||||
scope: "dm" | "group";
|
||||
entries: string[];
|
||||
}) {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const token = account.userToken || account.botToken?.trim();
|
||||
if (!token) {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
const resolved = await resolveSlackUserAllowlist({ token, entries: params.entries });
|
||||
return mapResolvedAllowlistNames(resolved);
|
||||
const plugin = getChannelPlugin(params.channelId);
|
||||
const resolved = await plugin?.allowlist?.resolveNames?.({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
scope: params.scope,
|
||||
entries: params.entries,
|
||||
});
|
||||
return mapResolvedAllowlistNames(resolved ?? []);
|
||||
}
|
||||
|
||||
async function resolveDiscordNames(params: {
|
||||
async function readAllowlistConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channelId: ChannelId;
|
||||
accountId?: string | null;
|
||||
entries: string[];
|
||||
}) {
|
||||
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const token = account.token?.trim();
|
||||
if (!token) {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
const resolved = await resolveDiscordUserAllowlist({ token, entries: params.entries });
|
||||
return mapResolvedAllowlistNames(resolved);
|
||||
const plugin = getChannelPlugin(params.channelId);
|
||||
return (
|
||||
(await plugin?.allowlist?.readConfig?.({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
})) ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
export const handleAllowlistCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
@ -425,83 +372,31 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
||||
};
|
||||
}
|
||||
const accountId = normalizeAccountId(parsed.account ?? params.ctx.AccountId);
|
||||
const scope = parsed.scope;
|
||||
const plugin = getChannelPlugin(channelId);
|
||||
|
||||
if (parsed.action === "list") {
|
||||
const pairingChannels = listPairingChannels();
|
||||
const supportsStore = pairingChannels.includes(channelId);
|
||||
const supportsStore = listPairingChannels().includes(channelId);
|
||||
if (!plugin?.allowlist?.readConfig && !supportsStore) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${channelId} does not expose allowlist configuration.` },
|
||||
};
|
||||
}
|
||||
const storeAllowFrom = supportsStore
|
||||
? await readChannelAllowFromStore(channelId, process.env, accountId).catch(() => [])
|
||||
: [];
|
||||
const configState = await readAllowlistConfig({
|
||||
cfg: params.cfg,
|
||||
channelId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
let dmAllowFrom: string[] = [];
|
||||
let groupAllowFrom: string[] = [];
|
||||
let groupOverrides: Array<{ label: string; entries: string[] }> = [];
|
||||
let dmPolicy: string | undefined;
|
||||
let groupPolicy: string | undefined;
|
||||
|
||||
if (channelId === "telegram") {
|
||||
const account = resolveTelegramAccount({ cfg: params.cfg, accountId });
|
||||
({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account));
|
||||
const groups = account.config.groups ?? {};
|
||||
for (const [groupId, groupCfg] of Object.entries(groups)) {
|
||||
const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean);
|
||||
if (entries.length > 0) {
|
||||
groupOverrides.push({ label: groupId, entries });
|
||||
}
|
||||
const topics = groupCfg?.topics ?? {};
|
||||
for (const [topicId, topicCfg] of Object.entries(topics)) {
|
||||
const topicEntries = (topicCfg?.allowFrom ?? []).map(String).filter(Boolean);
|
||||
if (topicEntries.length > 0) {
|
||||
groupOverrides.push({ label: `${groupId} topic ${topicId}`, entries: topicEntries });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (channelId === "whatsapp") {
|
||||
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId });
|
||||
dmAllowFrom = (account.allowFrom ?? []).map(String);
|
||||
groupAllowFrom = (account.groupAllowFrom ?? []).map(String);
|
||||
dmPolicy = account.dmPolicy;
|
||||
groupPolicy = account.groupPolicy;
|
||||
} else if (channelId === "signal") {
|
||||
const account = resolveSignalAccount({ cfg: params.cfg, accountId });
|
||||
({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account));
|
||||
} else if (channelId === "imessage") {
|
||||
const account = resolveIMessageAccount({ cfg: params.cfg, accountId });
|
||||
({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account));
|
||||
} else if (channelId === "slack") {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId });
|
||||
dmAllowFrom = (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String);
|
||||
groupPolicy = account.groupPolicy;
|
||||
const channels = account.channels ?? {};
|
||||
groupOverrides = Object.entries(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[] }>;
|
||||
} else if (channelId === "discord") {
|
||||
const account = resolveDiscordAccount({ cfg: params.cfg, accountId });
|
||||
dmAllowFrom = (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String);
|
||||
groupPolicy = account.config.groupPolicy;
|
||||
const guilds = account.config.guilds ?? {};
|
||||
for (const [guildKey, guildCfg] of Object.entries(guilds)) {
|
||||
const entries = (guildCfg?.users ?? []).map(String).filter(Boolean);
|
||||
if (entries.length > 0) {
|
||||
groupOverrides.push({ label: `guild ${guildKey}`, entries });
|
||||
}
|
||||
const channels = guildCfg?.channels ?? {};
|
||||
for (const [channelKey, channelCfg] of Object.entries(channels)) {
|
||||
const channelEntries = (channelCfg?.users ?? []).map(String).filter(Boolean);
|
||||
if (channelEntries.length > 0) {
|
||||
groupOverrides.push({
|
||||
label: `guild ${guildKey} / channel ${channelKey}`,
|
||||
entries: channelEntries,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const dmAllowFrom = (configState.dmAllowFrom ?? []).map(String);
|
||||
const groupAllowFrom = (configState.groupAllowFrom ?? []).map(String);
|
||||
const groupOverrides = (configState.groupOverrides ?? []).map((entry) => ({
|
||||
label: entry.label,
|
||||
entries: entry.entries.map(String).filter(Boolean),
|
||||
}));
|
||||
|
||||
const dmDisplay = normalizeAllowFrom({
|
||||
cfg: params.cfg,
|
||||
@ -522,38 +417,39 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
||||
accountId,
|
||||
values: groupOverrideEntries,
|
||||
});
|
||||
|
||||
const resolvedDm =
|
||||
parsed.resolve && dmDisplay.length > 0 && channelId === "slack"
|
||||
? await resolveSlackNames({ cfg: params.cfg, accountId, entries: dmDisplay })
|
||||
: parsed.resolve && dmDisplay.length > 0 && channelId === "discord"
|
||||
? await resolveDiscordNames({ cfg: params.cfg, accountId, entries: dmDisplay })
|
||||
: undefined;
|
||||
const resolvedGroup =
|
||||
parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "slack"
|
||||
? await resolveSlackNames({
|
||||
parsed.resolve && dmDisplay.length > 0
|
||||
? await resolveAllowlistNames({
|
||||
cfg: params.cfg,
|
||||
channelId,
|
||||
accountId,
|
||||
scope: "dm",
|
||||
entries: dmDisplay,
|
||||
})
|
||||
: undefined;
|
||||
const resolvedGroup =
|
||||
parsed.resolve && groupOverrideDisplay.length > 0
|
||||
? await resolveAllowlistNames({
|
||||
cfg: params.cfg,
|
||||
channelId,
|
||||
accountId,
|
||||
scope: "group",
|
||||
entries: groupOverrideDisplay,
|
||||
})
|
||||
: parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "discord"
|
||||
? await resolveDiscordNames({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
entries: groupOverrideDisplay,
|
||||
})
|
||||
: undefined;
|
||||
: undefined;
|
||||
|
||||
const lines: string[] = ["🧾 Allowlist"];
|
||||
lines.push(`Channel: ${channelId}${accountId ? ` (account ${accountId})` : ""}`);
|
||||
if (dmPolicy) {
|
||||
lines.push(`DM policy: ${dmPolicy}`);
|
||||
if (configState.dmPolicy) {
|
||||
lines.push(`DM policy: ${configState.dmPolicy}`);
|
||||
}
|
||||
if (groupPolicy) {
|
||||
lines.push(`Group policy: ${groupPolicy}`);
|
||||
if (configState.groupPolicy) {
|
||||
lines.push(`Group policy: ${configState.groupPolicy}`);
|
||||
}
|
||||
|
||||
const showDm = scope === "dm" || scope === "all";
|
||||
const showGroup = scope === "group" || scope === "all";
|
||||
const showDm = parsed.scope === "dm" || parsed.scope === "all";
|
||||
const showGroup = parsed.scope === "group" || parsed.scope === "all";
|
||||
if (showDm) {
|
||||
lines.push(`DM allowFrom (config): ${formatEntryList(dmDisplay, resolvedDm)}`);
|
||||
}
|
||||
@ -568,7 +464,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
||||
}
|
||||
if (showGroup) {
|
||||
if (groupAllowFrom.length > 0) {
|
||||
lines.push(`Group allowFrom (config): ${formatEntryList(groupDisplay)}`);
|
||||
lines.push(`Group allowFrom (config): ${formatEntryList(groupDisplay, resolvedGroup)}`);
|
||||
}
|
||||
if (groupOverrides.length > 0) {
|
||||
lines.push("Group overrides:");
|
||||
@ -600,12 +496,29 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
||||
const shouldTouchStore = parsed.target !== "config" && listPairingChannels().includes(channelId);
|
||||
|
||||
if (shouldUpdateConfig) {
|
||||
const allowlistPath = resolveChannelAllowFromPaths(channelId, scope);
|
||||
if (!allowlistPath) {
|
||||
if (parsed.scope === "all") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚠️ /allowlist add|remove requires scope dm or group." },
|
||||
};
|
||||
}
|
||||
const {
|
||||
target,
|
||||
pathPrefix,
|
||||
accountId: normalizedAccountId,
|
||||
writeTarget,
|
||||
} = resolveAccountTarget(structuredClone({ channels: {} }), channelId, accountId);
|
||||
void target;
|
||||
const editSpec = plugin?.allowlist?.resolveConfigEdit?.({
|
||||
scope: parsed.scope,
|
||||
pathPrefix,
|
||||
writeTarget,
|
||||
});
|
||||
if (!editSpec) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚠️ ${channelId} does not support ${scope} allowlist edits via /allowlist.`,
|
||||
text: `⚠️ ${channelId} does not support ${parsed.scope} allowlist edits via /allowlist.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -618,19 +531,14 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
||||
};
|
||||
}
|
||||
const parsedConfig = structuredClone(snapshot.parsed as Record<string, unknown>);
|
||||
const {
|
||||
target,
|
||||
pathPrefix,
|
||||
accountId: normalizedAccountId,
|
||||
writeTarget,
|
||||
} = resolveAccountTarget(parsedConfig, channelId, accountId);
|
||||
const resolvedTarget = resolveAccountTarget(parsedConfig, channelId, accountId);
|
||||
const deniedText = resolveConfigWriteDeniedText({
|
||||
cfg: params.cfg,
|
||||
channel: params.command.channel,
|
||||
channelId,
|
||||
accountId: params.ctx.AccountId,
|
||||
gatewayClientScopes: params.ctx.GatewayClientScopes,
|
||||
target: writeTarget,
|
||||
target: editSpec.writeTarget,
|
||||
});
|
||||
if (deniedText) {
|
||||
return {
|
||||
@ -642,13 +550,8 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
||||
}
|
||||
|
||||
const existing: string[] = [];
|
||||
const existingPaths =
|
||||
scope === "dm" && (channelId === "slack" || channelId === "discord")
|
||||
? // Read both while legacy alias may still exist; write canonical below.
|
||||
[allowlistPath, ["dm", "allowFrom"]]
|
||||
: [allowlistPath];
|
||||
for (const path of existingPaths) {
|
||||
const existingRaw = getNestedValue(target, path);
|
||||
for (const path of editSpec.readPaths) {
|
||||
const existingRaw = getNestedValue(resolvedTarget.target, path);
|
||||
if (!Array.isArray(existingRaw)) {
|
||||
continue;
|
||||
}
|
||||
@ -713,13 +616,12 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
||||
|
||||
if (configChanged) {
|
||||
if (next.length === 0) {
|
||||
deleteNestedValue(target, allowlistPath);
|
||||
deleteNestedValue(resolvedTarget.target, editSpec.writePath);
|
||||
} else {
|
||||
setNestedValue(target, allowlistPath, next);
|
||||
setNestedValue(resolvedTarget.target, editSpec.writePath, next);
|
||||
}
|
||||
if (scope === "dm" && (channelId === "slack" || channelId === "discord")) {
|
||||
// Remove legacy DM allowlist alias to prevent drift.
|
||||
deleteNestedValue(target, ["dm", "allowFrom"]);
|
||||
for (const path of editSpec.cleanupPaths ?? []) {
|
||||
deleteNestedValue(resolvedTarget.target, path);
|
||||
}
|
||||
}
|
||||
|
||||
@ -750,10 +652,10 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
||||
}
|
||||
|
||||
const actionLabel = parsed.action === "add" ? "added" : "removed";
|
||||
const scopeLabel = scope === "dm" ? "DM" : "group";
|
||||
const scopeLabel = parsed.scope === "dm" ? "DM" : "group";
|
||||
const locations: string[] = [];
|
||||
if (configChanged) {
|
||||
locations.push(`${pathPrefix}.${allowlistPath.join(".")}`);
|
||||
locations.push(`${resolvedTarget.pathPrefix}.${editSpec.writePath.join(".")}`);
|
||||
}
|
||||
if (shouldTouchStore) {
|
||||
locations.push("pairing store");
|
||||
@ -782,7 +684,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
||||
});
|
||||
|
||||
const actionLabel = parsed.action === "add" ? "added" : "removed";
|
||||
const scopeLabel = scope === "dm" ? "DM" : "group";
|
||||
const scopeLabel = parsed.scope === "dm" ? "DM" : "group";
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `✅ ${scopeLabel} allowlist ${actionLabel} in pairing store.` },
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
listSubagentRunsForRequester,
|
||||
resetSubagentRegistryForTests,
|
||||
} from "../../agents/subagent-registry.js";
|
||||
import { setDefaultChannelPluginRegistryForTests } from "../../commands/channel-test-helpers.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { updateSessionStore, type SessionEntry } from "../../config/sessions.js";
|
||||
import * as internalHooks from "../../hooks/internal-hooks.js";
|
||||
@ -133,6 +134,32 @@ afterAll(async () => {
|
||||
await fs.rm(testWorkspaceDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setDefaultChannelPluginRegistryForTests();
|
||||
readConfigFileSnapshotMock.mockImplementation(async () => {
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
return { valid: false, parsed: null };
|
||||
}
|
||||
const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record<string, unknown>;
|
||||
return { valid: true, parsed };
|
||||
});
|
||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||
ok: true,
|
||||
config,
|
||||
}));
|
||||
writeConfigFileMock.mockImplementation(async (config: unknown) => {
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
return;
|
||||
}
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
|
||||
});
|
||||
readChannelAllowFromStoreMock.mockResolvedValue([]);
|
||||
addChannelAllowFromStoreEntryMock.mockResolvedValue({ changed: true, allowFrom: [] });
|
||||
removeChannelAllowFromStoreEntryMock.mockResolvedValue({ changed: true, allowFrom: [] });
|
||||
});
|
||||
|
||||
async function withTempConfigPath<T>(
|
||||
initialConfig: Record<string, unknown>,
|
||||
run: (configPath: string) => Promise<T>,
|
||||
@ -998,6 +1025,7 @@ function buildPolicyParams(
|
||||
describe("handleCommands /allowlist", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setDefaultChannelPluginRegistryForTests();
|
||||
});
|
||||
|
||||
it("lists config + store allowFrom entries", async () => {
|
||||
|
||||
@ -1,31 +1,125 @@
|
||||
import { markdownToSignalTextChunks } from "../../../../extensions/signal/src/format.js";
|
||||
import { sendMessageSignal } from "../../../../extensions/signal/src/send.js";
|
||||
import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js";
|
||||
import { resolveMarkdownTableMode } from "../../../config/markdown-tables.js";
|
||||
import {
|
||||
resolveOutboundSendDep,
|
||||
type OutboundSendDeps,
|
||||
} from "../../../infra/outbound/send-deps.js";
|
||||
import {
|
||||
createScopedChannelMediaMaxBytesResolver,
|
||||
createDirectTextMediaOutbound,
|
||||
} from "./direct-text-media.js";
|
||||
import type { ChannelOutboundAdapter } from "../types.js";
|
||||
import { createScopedChannelMediaMaxBytesResolver } from "./direct-text-media.js";
|
||||
|
||||
function resolveSignalSender(deps: OutboundSendDeps | undefined) {
|
||||
return resolveOutboundSendDep<typeof sendMessageSignal>(deps, "signal") ?? sendMessageSignal;
|
||||
}
|
||||
|
||||
export const signalOutbound = createDirectTextMediaOutbound({
|
||||
channel: "signal",
|
||||
resolveSender: resolveSignalSender,
|
||||
resolveMaxBytes: createScopedChannelMediaMaxBytesResolver("signal"),
|
||||
buildTextOptions: ({ cfg, maxBytes, accountId }) => ({
|
||||
cfg,
|
||||
maxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
buildMediaOptions: ({ cfg, mediaUrl, maxBytes, accountId, mediaLocalRoots }) => ({
|
||||
const resolveSignalMaxBytes = createScopedChannelMediaMaxBytesResolver("signal");
|
||||
type SignalSendOpts = NonNullable<Parameters<typeof sendMessageSignal>[2]>;
|
||||
|
||||
function inferSignalTableMode(params: { cfg: SignalSendOpts["cfg"]; accountId?: string | null }) {
|
||||
return resolveMarkdownTableMode({
|
||||
cfg: params.cfg,
|
||||
channel: "signal",
|
||||
accountId: params.accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export const signalOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, _limit) => text.split(/\n{2,}/).flatMap((chunk) => (chunk ? [chunk] : [])),
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 4000,
|
||||
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => {
|
||||
const send = resolveSignalSender(deps);
|
||||
const maxBytes = resolveSignalMaxBytes({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
const limit = resolveTextChunkLimit(cfg, "signal", accountId ?? undefined, {
|
||||
fallbackLimit: 4000,
|
||||
});
|
||||
const tableMode = inferSignalTableMode({ cfg, accountId });
|
||||
let chunks =
|
||||
limit === undefined
|
||||
? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { tableMode })
|
||||
: markdownToSignalTextChunks(text, limit, { tableMode });
|
||||
if (chunks.length === 0 && text) {
|
||||
chunks = [{ text, styles: [] }];
|
||||
}
|
||||
const results = [];
|
||||
for (const chunk of chunks) {
|
||||
abortSignal?.throwIfAborted();
|
||||
const result = await send(to, chunk.text, {
|
||||
cfg,
|
||||
maxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
textMode: "plain",
|
||||
textStyles: chunk.styles,
|
||||
});
|
||||
results.push({ channel: "signal" as const, ...result });
|
||||
}
|
||||
return results;
|
||||
},
|
||||
sendFormattedMedia: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
mediaLocalRoots,
|
||||
}),
|
||||
});
|
||||
accountId,
|
||||
deps,
|
||||
abortSignal,
|
||||
}) => {
|
||||
abortSignal?.throwIfAborted();
|
||||
const send = resolveSignalSender(deps);
|
||||
const maxBytes = resolveSignalMaxBytes({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
const tableMode = inferSignalTableMode({ cfg, accountId });
|
||||
const formatted = markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, {
|
||||
tableMode,
|
||||
})[0] ?? {
|
||||
text,
|
||||
styles: [],
|
||||
};
|
||||
const result = await send(to, formatted.text, {
|
||||
cfg,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
textMode: "plain",
|
||||
textStyles: formatted.styles,
|
||||
mediaLocalRoots,
|
||||
});
|
||||
return { channel: "signal", ...result };
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||
const send = resolveSignalSender(deps);
|
||||
const maxBytes = resolveSignalMaxBytes({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
cfg,
|
||||
maxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
return { channel: "signal", ...result };
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => {
|
||||
const send = resolveSignalSender(deps);
|
||||
const maxBytes = resolveSignalMaxBytes({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
cfg,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
mediaLocalRoots,
|
||||
});
|
||||
return { channel: "signal", ...result };
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { AgentAcpBinding } from "../../config/types.js";
|
||||
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
|
||||
import type { ExecApprovalRequest, ExecApprovalResolved } from "../../infra/exec-approvals.js";
|
||||
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
|
||||
import type { OutboundIdentity } from "../../infra/outbound/identity.js";
|
||||
import type { PluginRuntime } from "../../plugins/runtime/types.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import type { ConfigWriteTarget } from "./config-writes.js";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelAccountState,
|
||||
@ -137,12 +139,23 @@ export type ChannelOutboundPayloadContext = ChannelOutboundContext & {
|
||||
payload: ReplyPayload;
|
||||
};
|
||||
|
||||
export type ChannelOutboundFormattedContext = ChannelOutboundContext & {
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct" | "gateway" | "hybrid";
|
||||
chunker?: ((text: string, limit: number) => string[]) | null;
|
||||
chunkerMode?: "text" | "markdown";
|
||||
textChunkLimit?: number;
|
||||
pollMaxOptions?: number;
|
||||
normalizePayload?: (params: { payload: ReplyPayload }) => ReplyPayload | null;
|
||||
shouldSkipPlainTextSanitization?: (params: { payload: ReplyPayload }) => boolean;
|
||||
resolveEffectiveTextChunkLimit?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
fallbackLimit?: number;
|
||||
}) => number | undefined;
|
||||
resolveTarget?: (params: {
|
||||
cfg?: OpenClawConfig;
|
||||
to?: string;
|
||||
@ -151,6 +164,10 @@ export type ChannelOutboundAdapter = {
|
||||
mode?: ChannelOutboundTargetMode;
|
||||
}) => { ok: true; to: string } | { ok: false; error: Error };
|
||||
sendPayload?: (ctx: ChannelOutboundPayloadContext) => Promise<OutboundDeliveryResult>;
|
||||
sendFormattedText?: (ctx: ChannelOutboundFormattedContext) => Promise<OutboundDeliveryResult[]>;
|
||||
sendFormattedMedia?: (
|
||||
ctx: ChannelOutboundFormattedContext & { mediaUrl: string },
|
||||
) => Promise<OutboundDeliveryResult>;
|
||||
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
|
||||
sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
|
||||
sendPoll?: (ctx: ChannelPollContext) => Promise<ChannelPollResult>;
|
||||
@ -464,9 +481,63 @@ export type ChannelExecApprovalAdapter = {
|
||||
};
|
||||
|
||||
export type ChannelAllowlistAdapter = {
|
||||
readConfig?: (params: { cfg: OpenClawConfig; accountId?: string | null }) =>
|
||||
| {
|
||||
dmAllowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
dmPolicy?: string;
|
||||
groupPolicy?: string;
|
||||
groupOverrides?: Array<{ label: string; entries: Array<string | number> }>;
|
||||
}
|
||||
| Promise<{
|
||||
dmAllowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
dmPolicy?: string;
|
||||
groupPolicy?: string;
|
||||
groupOverrides?: Array<{ label: string; entries: Array<string | number> }>;
|
||||
}>;
|
||||
resolveNames?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
scope: "dm" | "group";
|
||||
entries: string[];
|
||||
}) =>
|
||||
| Array<{ input: string; resolved: boolean; name?: string | null }>
|
||||
| Promise<Array<{ input: string; resolved: boolean; name?: string | null }>>;
|
||||
resolveConfigEdit?: (params: {
|
||||
scope: "dm" | "group";
|
||||
pathPrefix: string;
|
||||
writeTarget: ConfigWriteTarget;
|
||||
}) => {
|
||||
pathPrefix: string;
|
||||
writeTarget: ConfigWriteTarget;
|
||||
readPaths: string[][];
|
||||
writePath: string[];
|
||||
cleanupPaths?: string[][];
|
||||
} | null;
|
||||
supportsScope?: (params: { scope: "dm" | "group" | "all" }) => boolean;
|
||||
};
|
||||
|
||||
export type ChannelAcpBindingAdapter = {
|
||||
normalizeConfiguredBindingTarget?: (params: {
|
||||
binding: AgentAcpBinding;
|
||||
conversationId: string;
|
||||
}) => {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
} | null;
|
||||
matchConfiguredBinding?: (params: {
|
||||
binding: AgentAcpBinding;
|
||||
bindingConversationId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}) => {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
matchPriority?: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ChannelSecurityAdapter<ResolvedAccount = unknown> = {
|
||||
resolveDmPolicy?: (
|
||||
ctx: ChannelSecurityContext<ResolvedAccount>,
|
||||
|
||||
@ -345,6 +345,12 @@ export type ChannelThreadingToolContext = {
|
||||
|
||||
export type ChannelMessagingAdapter = {
|
||||
normalizeTarget?: (raw: string) => string | undefined;
|
||||
parseExplicitTarget?: (params: { raw: string }) => {
|
||||
to: string;
|
||||
threadId?: string | number;
|
||||
chatType?: ChatType;
|
||||
} | null;
|
||||
inferTargetChatType?: (params: { to: string }) => ChatType | undefined;
|
||||
buildCrossContextComponents?: ChannelCrossContextComponentsFactory;
|
||||
enableInteractiveReplies?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
|
||||
@ -17,6 +17,7 @@ import type {
|
||||
ChannelSetupAdapter,
|
||||
ChannelStatusAdapter,
|
||||
ChannelAllowlistAdapter,
|
||||
ChannelAcpBindingAdapter,
|
||||
} from "./types.adapters.js";
|
||||
import type {
|
||||
ChannelAgentTool,
|
||||
@ -77,6 +78,7 @@ export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknow
|
||||
lifecycle?: ChannelLifecycleAdapter;
|
||||
execApprovals?: ChannelExecApprovalAdapter;
|
||||
allowlist?: ChannelAllowlistAdapter;
|
||||
acpBindings?: ChannelAcpBindingAdapter;
|
||||
streaming?: ChannelStreamingAdapter;
|
||||
threading?: ChannelThreadingAdapter;
|
||||
messaging?: ChannelMessagingAdapter;
|
||||
|
||||
@ -33,6 +33,7 @@ export type {
|
||||
ChannelOutboundAdapter,
|
||||
ChannelOutboundContext,
|
||||
ChannelAllowlistAdapter,
|
||||
ChannelAcpBindingAdapter,
|
||||
ChannelPairingAdapter,
|
||||
ChannelSecurityAdapter,
|
||||
ChannelSetupAdapter,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { discordPlugin } from "../../extensions/discord/src/channel.js";
|
||||
import { feishuPlugin } from "../../extensions/feishu/src/channel.js";
|
||||
import { imessagePlugin } from "../../extensions/imessage/src/channel.js";
|
||||
import { signalPlugin } from "../../extensions/signal/src/channel.js";
|
||||
import { slackPlugin } from "../../extensions/slack/src/channel.js";
|
||||
@ -27,6 +28,7 @@ type PatchedSetupAdapterFields = {
|
||||
export function setDefaultChannelPluginRegistryForTests(): void {
|
||||
const channels = [
|
||||
{ pluginId: "discord", plugin: discordPlugin, source: "test" },
|
||||
{ pluginId: "feishu", plugin: feishuPlugin, source: "test" },
|
||||
{ pluginId: "slack", plugin: slackPlugin, source: "test" },
|
||||
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
|
||||
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
|
||||
|
||||
@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseTelegramTarget } from "../../extensions/telegram/src/targets.js";
|
||||
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
|
||||
import * as replyModule from "../auto-reply/reply.js";
|
||||
import { whatsappOutbound } from "../channels/plugins/outbound/whatsapp.js";
|
||||
@ -80,6 +81,20 @@ beforeAll(async () => {
|
||||
return { channel: "telegram", messageId: res.messageId, chatId: res.chatId };
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
parseExplicitTarget: ({ raw }) => {
|
||||
const target = parseTelegramTarget(raw);
|
||||
return {
|
||||
to: target.chatId,
|
||||
threadId: target.messageThreadId,
|
||||
chatType: target.chatType === "unknown" ? undefined : target.chatType,
|
||||
};
|
||||
},
|
||||
inferTargetChatType: ({ to }) => {
|
||||
const target = parseTelegramTarget(to);
|
||||
return target.chatType === "unknown" ? undefined : target.chatType;
|
||||
},
|
||||
},
|
||||
});
|
||||
telegramPlugin.config = {
|
||||
...telegramPlugin.config,
|
||||
|
||||
@ -1,8 +1,3 @@
|
||||
import {
|
||||
markdownToSignalTextChunks,
|
||||
type SignalTextStyleRange,
|
||||
} from "../../../extensions/signal/src/format.js";
|
||||
import { sendMessageSignal } from "../../../extensions/signal/src/send.js";
|
||||
import {
|
||||
chunkByParagraph,
|
||||
chunkMarkdownTextWithMode,
|
||||
@ -10,14 +5,12 @@ import {
|
||||
resolveTextChunkLimit,
|
||||
} from "../../auto-reply/chunk.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits.js";
|
||||
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
|
||||
import type {
|
||||
ChannelOutboundAdapter,
|
||||
ChannelOutboundContext,
|
||||
} from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
||||
import {
|
||||
appendAssistantMessageToSessionTranscript,
|
||||
resolveMirroredTranscriptText,
|
||||
@ -51,7 +44,6 @@ export { normalizeOutboundPayloads } from "./payloads.js";
|
||||
export { resolveOutboundSendDep, type OutboundSendDeps } from "./send-deps.js";
|
||||
|
||||
const log = createSubsystemLogger("outbound/deliver");
|
||||
const TELEGRAM_TEXT_LIMIT = 4096;
|
||||
|
||||
export type OutboundDeliveryResult = {
|
||||
channel: Exclude<OutboundChannel, "none">;
|
||||
@ -74,6 +66,9 @@ type ChannelHandler = {
|
||||
chunkerMode?: "text" | "markdown";
|
||||
textChunkLimit?: number;
|
||||
supportsMedia: boolean;
|
||||
normalizePayload?: (payload: ReplyPayload) => ReplyPayload | null;
|
||||
shouldSkipPlainTextSanitization?: (payload: ReplyPayload) => boolean;
|
||||
resolveEffectiveTextChunkLimit?: (fallbackLimit?: number) => number | undefined;
|
||||
sendPayload?: (
|
||||
payload: ReplyPayload,
|
||||
overrides?: {
|
||||
@ -81,6 +76,21 @@ type ChannelHandler = {
|
||||
threadId?: string | number | null;
|
||||
},
|
||||
) => Promise<OutboundDeliveryResult>;
|
||||
sendFormattedText?: (
|
||||
text: string,
|
||||
overrides?: {
|
||||
replyToId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
},
|
||||
) => Promise<OutboundDeliveryResult[]>;
|
||||
sendFormattedMedia?: (
|
||||
caption: string,
|
||||
mediaUrl: string,
|
||||
overrides?: {
|
||||
replyToId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
},
|
||||
) => Promise<OutboundDeliveryResult>;
|
||||
sendText: (
|
||||
text: string,
|
||||
overrides?: {
|
||||
@ -155,6 +165,20 @@ function createPluginHandler(
|
||||
chunkerMode,
|
||||
textChunkLimit: outbound.textChunkLimit,
|
||||
supportsMedia: Boolean(sendMedia),
|
||||
normalizePayload: outbound.normalizePayload
|
||||
? (payload) => outbound.normalizePayload!({ payload })
|
||||
: undefined,
|
||||
shouldSkipPlainTextSanitization: outbound.shouldSkipPlainTextSanitization
|
||||
? (payload) => outbound.shouldSkipPlainTextSanitization!({ payload })
|
||||
: undefined,
|
||||
resolveEffectiveTextChunkLimit: outbound.resolveEffectiveTextChunkLimit
|
||||
? (fallbackLimit) =>
|
||||
outbound.resolveEffectiveTextChunkLimit!({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId ?? undefined,
|
||||
fallbackLimit,
|
||||
})
|
||||
: undefined,
|
||||
sendPayload: outbound.sendPayload
|
||||
? async (payload, overrides) =>
|
||||
outbound.sendPayload!({
|
||||
@ -164,6 +188,21 @@ function createPluginHandler(
|
||||
payload,
|
||||
})
|
||||
: undefined,
|
||||
sendFormattedText: outbound.sendFormattedText
|
||||
? async (text, overrides) =>
|
||||
outbound.sendFormattedText!({
|
||||
...resolveCtx(overrides),
|
||||
text,
|
||||
})
|
||||
: undefined,
|
||||
sendFormattedMedia: outbound.sendFormattedMedia
|
||||
? async (caption, mediaUrl, overrides) =>
|
||||
outbound.sendFormattedMedia!({
|
||||
...resolveCtx(overrides),
|
||||
text: caption,
|
||||
mediaUrl,
|
||||
})
|
||||
: undefined,
|
||||
sendText: async (text, overrides) =>
|
||||
sendText({
|
||||
...resolveCtx(overrides),
|
||||
@ -239,18 +278,13 @@ type MessageSentEvent = {
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
function normalizePayloadForChannelDelivery(
|
||||
payload: ReplyPayload,
|
||||
channelId: string,
|
||||
): ReplyPayload | null {
|
||||
function normalizeEmptyPayloadForDelivery(payload: ReplyPayload): ReplyPayload | null {
|
||||
const text = typeof payload.text === "string" ? payload.text : "";
|
||||
const hasChannelData = hasReplyChannelData(payload.channelData);
|
||||
const rawText = typeof payload.text === "string" ? payload.text : "";
|
||||
const normalizedText =
|
||||
channelId === "whatsapp" ? rawText.replace(/^(?:[ \t]*\r?\n)+/, "") : rawText;
|
||||
if (!normalizedText.trim()) {
|
||||
if (!text.trim()) {
|
||||
if (
|
||||
!hasReplyContent({
|
||||
text: normalizedText,
|
||||
text,
|
||||
mediaUrl: payload.mediaUrl,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
interactive: payload.interactive,
|
||||
@ -259,26 +293,20 @@ function normalizePayloadForChannelDelivery(
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
text: "",
|
||||
};
|
||||
if (text) {
|
||||
return {
|
||||
...payload,
|
||||
text: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
if (normalizedText === rawText) {
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
text: normalizedText,
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
function normalizePayloadsForChannelDelivery(
|
||||
payloads: ReplyPayload[],
|
||||
channel: Exclude<OutboundChannel, "none">,
|
||||
_cfg: OpenClawConfig,
|
||||
_to: string,
|
||||
_accountId?: string,
|
||||
handler: ChannelHandler,
|
||||
): ReplyPayload[] {
|
||||
const normalizedPayloads: ReplyPayload[] = [];
|
||||
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
|
||||
@ -287,15 +315,19 @@ function normalizePayloadsForChannelDelivery(
|
||||
// Models occasionally produce <br>, <b>, etc. that render as literal text.
|
||||
// See https://github.com/openclaw/openclaw/issues/31884
|
||||
if (isPlainTextSurface(channel) && sanitizedPayload.text) {
|
||||
// Telegram sendPayload uses textMode:"html". Preserve raw HTML in this path.
|
||||
if (!(channel === "telegram" && sanitizedPayload.channelData)) {
|
||||
if (!handler.shouldSkipPlainTextSanitization?.(sanitizedPayload)) {
|
||||
sanitizedPayload = {
|
||||
...sanitizedPayload,
|
||||
text: sanitizeForPlainText(sanitizedPayload.text),
|
||||
};
|
||||
}
|
||||
}
|
||||
const normalized = normalizePayloadForChannelDelivery(sanitizedPayload, channel);
|
||||
const normalizedPayload = handler.normalizePayload
|
||||
? handler.normalizePayload(sanitizedPayload)
|
||||
: sanitizedPayload;
|
||||
const normalized = normalizedPayload
|
||||
? normalizeEmptyPayloadForDelivery(normalizedPayload)
|
||||
: null;
|
||||
if (normalized) {
|
||||
normalizedPayloads.push(normalized);
|
||||
}
|
||||
@ -513,8 +545,6 @@ async function deliverOutboundPayloadsCore(
|
||||
const accountId = params.accountId;
|
||||
const deps = params.deps;
|
||||
const abortSignal = params.abortSignal;
|
||||
const sendSignal =
|
||||
resolveOutboundSendDep<typeof sendMessageSignal>(params.deps, "signal") ?? sendMessageSignal;
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(
|
||||
cfg,
|
||||
params.session?.agentId ?? params.mirror?.agentId,
|
||||
@ -539,24 +569,10 @@ async function deliverOutboundPayloadsCore(
|
||||
fallbackLimit: handler.textChunkLimit,
|
||||
})
|
||||
: undefined;
|
||||
const textLimit =
|
||||
channel === "telegram" && typeof configuredTextLimit === "number"
|
||||
? Math.min(configuredTextLimit, TELEGRAM_TEXT_LIMIT)
|
||||
: configuredTextLimit;
|
||||
const textLimit = handler.resolveEffectiveTextChunkLimit
|
||||
? handler.resolveEffectiveTextChunkLimit(configuredTextLimit)
|
||||
: configuredTextLimit;
|
||||
const chunkMode = handler.chunker ? resolveChunkMode(cfg, channel, accountId) : "length";
|
||||
const isSignalChannel = channel === "signal";
|
||||
const signalTableMode = isSignalChannel
|
||||
? resolveMarkdownTableMode({ cfg, channel: "signal", accountId })
|
||||
: "code";
|
||||
const signalMaxBytes = isSignalChannel
|
||||
? resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||
cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ??
|
||||
cfg.channels?.signal?.mediaMaxMb,
|
||||
accountId,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const sendTextChunks = async (
|
||||
text: string,
|
||||
@ -595,66 +611,7 @@ async function deliverOutboundPayloadsCore(
|
||||
results.push(await handler.sendText(chunk, overrides));
|
||||
}
|
||||
};
|
||||
|
||||
const sendSignalText = async (text: string, styles: SignalTextStyleRange[]) => {
|
||||
throwIfAborted(abortSignal);
|
||||
return {
|
||||
channel: "signal" as const,
|
||||
...(await sendSignal(to, text, {
|
||||
cfg,
|
||||
maxBytes: signalMaxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
textMode: "plain",
|
||||
textStyles: styles,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const sendSignalTextChunks = async (text: string) => {
|
||||
throwIfAborted(abortSignal);
|
||||
let signalChunks =
|
||||
textLimit === undefined
|
||||
? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, {
|
||||
tableMode: signalTableMode,
|
||||
})
|
||||
: markdownToSignalTextChunks(text, textLimit, { tableMode: signalTableMode });
|
||||
if (signalChunks.length === 0 && text) {
|
||||
signalChunks = [{ text, styles: [] }];
|
||||
}
|
||||
for (const chunk of signalChunks) {
|
||||
throwIfAborted(abortSignal);
|
||||
results.push(await sendSignalText(chunk.text, chunk.styles));
|
||||
}
|
||||
};
|
||||
|
||||
const sendSignalMedia = async (caption: string, mediaUrl: string) => {
|
||||
throwIfAborted(abortSignal);
|
||||
const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY, {
|
||||
tableMode: signalTableMode,
|
||||
})[0] ?? {
|
||||
text: caption,
|
||||
styles: [],
|
||||
};
|
||||
return {
|
||||
channel: "signal" as const,
|
||||
...(await sendSignal(to, formatted.text, {
|
||||
cfg,
|
||||
mediaUrl,
|
||||
maxBytes: signalMaxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
textMode: "plain",
|
||||
textStyles: formatted.styles,
|
||||
mediaLocalRoots,
|
||||
})),
|
||||
};
|
||||
};
|
||||
const normalizedPayloads = normalizePayloadsForChannelDelivery(
|
||||
payloads,
|
||||
channel,
|
||||
cfg,
|
||||
to,
|
||||
accountId,
|
||||
);
|
||||
const normalizedPayloads = normalizePayloadsForChannelDelivery(payloads, channel, handler);
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
const sessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.session?.key;
|
||||
const mirrorIsGroup = params.mirror?.isGroup;
|
||||
@ -724,8 +681,8 @@ async function deliverOutboundPayloadsCore(
|
||||
}
|
||||
if (payloadSummary.mediaUrls.length === 0) {
|
||||
const beforeCount = results.length;
|
||||
if (isSignalChannel) {
|
||||
await sendSignalTextChunks(payloadSummary.text);
|
||||
if (handler.sendFormattedText) {
|
||||
results.push(...(await handler.sendFormattedText(payloadSummary.text, sendOverrides)));
|
||||
} else {
|
||||
await sendTextChunks(payloadSummary.text, sendOverrides);
|
||||
}
|
||||
@ -770,8 +727,8 @@ async function deliverOutboundPayloadsCore(
|
||||
throwIfAborted(abortSignal);
|
||||
const caption = first ? payloadSummary.text : "";
|
||||
first = false;
|
||||
if (isSignalChannel) {
|
||||
const delivery = await sendSignalMedia(caption, url);
|
||||
if (handler.sendFormattedMedia) {
|
||||
const delivery = await handler.sendFormattedMedia(caption, url, sendOverrides);
|
||||
results.push(delivery);
|
||||
lastMessageId = delivery.messageId;
|
||||
} else {
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js";
|
||||
import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js";
|
||||
import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.js";
|
||||
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions/types.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
|
||||
import {
|
||||
resolveHeartbeatDeliveryTarget,
|
||||
resolveOutboundTarget,
|
||||
@ -17,6 +21,76 @@ import {
|
||||
|
||||
runResolveOutboundTargetCoreTests();
|
||||
|
||||
const telegramMessaging = {
|
||||
parseExplicitTarget: ({ raw }: { raw: string }) => {
|
||||
const target = parseTelegramTarget(raw);
|
||||
return {
|
||||
to: target.chatId,
|
||||
threadId: target.messageThreadId,
|
||||
chatType: target.chatType === "unknown" ? undefined : target.chatType,
|
||||
};
|
||||
},
|
||||
inferTargetChatType: ({ to }: { to: string }) => {
|
||||
const target = parseTelegramTarget(to);
|
||||
return target.chatType === "unknown" ? undefined : target.chatType;
|
||||
},
|
||||
};
|
||||
|
||||
const whatsappMessaging = {
|
||||
inferTargetChatType: ({ to }: { to: string }) => {
|
||||
const normalized = normalizeWhatsAppTarget(to);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return isWhatsAppGroupJid(normalized) ? ("group" as const) : ("direct" as const);
|
||||
},
|
||||
};
|
||||
|
||||
const noopOutbound = (channel: "discord" | "imessage" | "slack"): ChannelOutboundAdapter => ({
|
||||
deliveryMode: "direct",
|
||||
sendText: async () => ({ channel, messageId: `${channel}-msg` }),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "discord",
|
||||
plugin: createOutboundTestPlugin({ id: "discord", outbound: noopOutbound("discord") }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "imessage",
|
||||
plugin: createOutboundTestPlugin({ id: "imessage", outbound: noopOutbound("imessage") }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "slack",
|
||||
plugin: createOutboundTestPlugin({ id: "slack", outbound: noopOutbound("slack") }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "telegram",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "telegram",
|
||||
outbound: telegramOutbound,
|
||||
messaging: telegramMessaging,
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "whatsapp",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "whatsapp",
|
||||
outbound: whatsappOutbound,
|
||||
messaging: whatsappMessaging,
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
describe("resolveOutboundTarget defaultTo config fallback", () => {
|
||||
installResolveOutboundTargetPluginRegistryHooks();
|
||||
const whatsappDefaultCfg: OpenClawConfig = {
|
||||
@ -80,7 +154,11 @@ describe("resolveOutboundTarget defaultTo config fallback", () => {
|
||||
|
||||
registry.channels.push({
|
||||
pluginId: "telegram",
|
||||
plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }),
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "telegram",
|
||||
outbound: telegramOutbound,
|
||||
messaging: telegramMessaging,
|
||||
}),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
|
||||
@ -1,9 +1,3 @@
|
||||
import { parseDiscordTarget } from "../../../extensions/discord/src/targets.js";
|
||||
import { parseSlackTarget } from "../../../extensions/slack/src/targets.js";
|
||||
import {
|
||||
parseTelegramTarget,
|
||||
resolveTelegramTargetChatType,
|
||||
} from "../../../extensions/telegram/src/targets.js";
|
||||
import { normalizeChatType, type ChatType } from "../../channels/chat-type.js";
|
||||
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
|
||||
import { formatCliCommand } from "../../cli/command-format.js";
|
||||
@ -22,7 +16,6 @@ import {
|
||||
isDeliverableMessageChannel,
|
||||
normalizeMessageChannel,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
|
||||
import {
|
||||
normalizeDeliverableOutboundChannel,
|
||||
resolveOutboundChannelPlugin,
|
||||
@ -65,6 +58,26 @@ export type SessionDeliveryTarget = {
|
||||
lastThreadId?: string | number;
|
||||
};
|
||||
|
||||
function parseExplicitTargetWithPlugin(params: {
|
||||
channel?: DeliverableMessageChannel;
|
||||
fallbackChannel?: DeliverableMessageChannel;
|
||||
raw?: string;
|
||||
}) {
|
||||
const raw = params.raw?.trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const provider = params.channel ?? params.fallbackChannel;
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
resolveOutboundChannelPlugin({ channel: provider })?.messaging?.parseExplicitTarget?.({
|
||||
raw,
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSessionDeliveryTarget(params: {
|
||||
entry?: SessionEntry;
|
||||
requestedChannel?: GatewayMessageChannel | "last";
|
||||
@ -124,22 +137,19 @@ export function resolveSessionDeliveryTarget(params: {
|
||||
channel = params.fallbackChannel;
|
||||
}
|
||||
|
||||
// Parse :topic:NNN from explicitTo (Telegram topic syntax).
|
||||
// Only applies when we positively know the channel is Telegram.
|
||||
// When channel is unknown, the downstream send path (resolveTelegramSession)
|
||||
// handles :topic: parsing independently.
|
||||
const isTelegramContext = channel === "telegram" || (!channel && lastChannel === "telegram");
|
||||
let explicitTo = rawExplicitTo;
|
||||
let parsedThreadId: number | undefined;
|
||||
if (isTelegramContext && rawExplicitTo && rawExplicitTo.includes(":topic:")) {
|
||||
const parsed = parseTelegramTarget(rawExplicitTo);
|
||||
explicitTo = parsed.chatId;
|
||||
parsedThreadId = parsed.messageThreadId;
|
||||
const parsedExplicitTarget = parseExplicitTargetWithPlugin({
|
||||
channel,
|
||||
fallbackChannel: !channel ? lastChannel : undefined,
|
||||
raw: rawExplicitTo,
|
||||
});
|
||||
if (parsedExplicitTarget?.to) {
|
||||
explicitTo = parsedExplicitTarget.to;
|
||||
}
|
||||
const explicitThreadId =
|
||||
params.explicitThreadId != null && params.explicitThreadId !== ""
|
||||
? params.explicitThreadId
|
||||
: parsedThreadId;
|
||||
: parsedExplicitTarget?.threadId;
|
||||
|
||||
let to = explicitTo;
|
||||
if (!to && lastTo) {
|
||||
@ -387,70 +397,6 @@ function buildNoHeartbeatDeliveryTarget(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function inferDiscordTargetChatType(to: string): ChatType | undefined {
|
||||
try {
|
||||
const target = parseDiscordTarget(to, { defaultKind: "channel" });
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
return target.kind === "user" ? "direct" : "channel";
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function inferSlackTargetChatType(to: string): ChatType | undefined {
|
||||
const target = parseSlackTarget(to, { defaultKind: "channel" });
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
return target.kind === "user" ? "direct" : "channel";
|
||||
}
|
||||
|
||||
function inferTelegramTargetChatType(to: string): ChatType | undefined {
|
||||
const chatType = resolveTelegramTargetChatType(to);
|
||||
return chatType === "unknown" ? undefined : chatType;
|
||||
}
|
||||
|
||||
function inferWhatsAppTargetChatType(to: string): ChatType | undefined {
|
||||
const normalized = normalizeWhatsAppTarget(to);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return isWhatsAppGroupJid(normalized) ? "group" : "direct";
|
||||
}
|
||||
|
||||
function inferSignalTargetChatType(rawTo: string): ChatType | undefined {
|
||||
let to = rawTo.trim();
|
||||
if (!to) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^signal:/i.test(to)) {
|
||||
to = to.replace(/^signal:/i, "").trim();
|
||||
}
|
||||
if (!to) {
|
||||
return undefined;
|
||||
}
|
||||
const lower = to.toLowerCase();
|
||||
if (lower.startsWith("group:")) {
|
||||
return "group";
|
||||
}
|
||||
if (lower.startsWith("username:") || lower.startsWith("u:")) {
|
||||
return "direct";
|
||||
}
|
||||
return "direct";
|
||||
}
|
||||
|
||||
const HEARTBEAT_TARGET_CHAT_TYPE_INFERERS: Partial<
|
||||
Record<DeliverableMessageChannel, (to: string) => ChatType | undefined>
|
||||
> = {
|
||||
discord: inferDiscordTargetChatType,
|
||||
slack: inferSlackTargetChatType,
|
||||
telegram: inferTelegramTargetChatType,
|
||||
whatsapp: inferWhatsAppTargetChatType,
|
||||
signal: inferSignalTargetChatType,
|
||||
};
|
||||
|
||||
function inferChatTypeFromTarget(params: {
|
||||
channel: DeliverableMessageChannel;
|
||||
to: string;
|
||||
@ -469,7 +415,9 @@ function inferChatTypeFromTarget(params: {
|
||||
if (/^group:/i.test(to)) {
|
||||
return "group";
|
||||
}
|
||||
return HEARTBEAT_TARGET_CHAT_TYPE_INFERERS[params.channel]?.(to);
|
||||
return resolveOutboundChannelPlugin({
|
||||
channel: params.channel,
|
||||
})?.messaging?.inferTargetChatType?.({ to });
|
||||
}
|
||||
|
||||
function resolveHeartbeatDeliveryChatType(params: {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ChannelCapabilities,
|
||||
ChannelId,
|
||||
ChannelMessagingAdapter,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelPlugin,
|
||||
} from "../channels/plugins/types.js";
|
||||
@ -96,6 +97,7 @@ export const createMSTeamsTestPlugin = (params?: {
|
||||
export const createOutboundTestPlugin = (params: {
|
||||
id: ChannelId;
|
||||
outbound: ChannelOutboundAdapter;
|
||||
messaging?: ChannelMessagingAdapter;
|
||||
label?: string;
|
||||
docsPath?: string;
|
||||
capabilities?: ChannelCapabilities;
|
||||
@ -108,4 +110,5 @@ export const createOutboundTestPlugin = (params: {
|
||||
config: { listAccountIds: () => [] },
|
||||
}),
|
||||
outbound: params.outbound,
|
||||
...(params.messaging ? { messaging: params.messaging } : {}),
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user