From 85b7bc7edf2884c0b1d3866adfd9218c1001a7f1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 00:09:28 -0700 Subject: [PATCH] refactor: remove dock shim and move session routing into plugins --- extensions/discord/src/channel.ts | 102 ++++ extensions/discord/src/draft-chunking.ts | 5 +- extensions/discord/src/outbound-adapter.ts | 4 +- extensions/googlechat/index.ts | 4 +- extensions/googlechat/setup-entry.ts | 3 +- extensions/googlechat/src/channel.ts | 28 - extensions/imessage/src/channel.ts | 80 +++ extensions/signal/src/channel.ts | 90 +++ extensions/slack/src/channel.ts | 161 +++++ extensions/telegram/src/channel.ts | 88 +++ extensions/telegram/src/draft-chunking.ts | 5 +- extensions/telegram/src/outbound-adapter.ts | 4 +- extensions/zalo/index.ts | 4 +- extensions/zalo/src/channel.ts | 23 - extensions/zalouser/index.ts | 4 +- extensions/zalouser/src/channel.ts | 28 +- src/channels/dock.test.ts | 194 ------ src/channels/dock.ts | 636 -------------------- src/channels/plugins/contracts/suites.ts | 8 +- src/channels/plugins/index.ts | 4 +- src/infra/outbound/outbound-session.test.ts | 7 +- src/infra/outbound/outbound-session.ts | 387 ------------ src/infra/outbound/outbound.test.ts | 9 + src/plugin-sdk/googlechat.ts | 1 - src/plugin-sdk/index.ts | 2 +- src/plugin-sdk/zalo.ts | 1 - src/plugin-sdk/zalouser.ts | 1 - src/plugins/loader.ts | 9 +- src/plugins/registry.ts | 3 - src/plugins/types.ts | 2 - 30 files changed, 565 insertions(+), 1332 deletions(-) delete mode 100644 src/channels/dock.test.ts delete mode 100644 src/channels/dock.ts diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 3c0da68a06a..f83be6bbe41 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,4 +1,9 @@ import { Separator, TextDisplay } from "@buape/carbon"; +import { + buildAgentSessionKey, + resolveThreadSessionKeys, + type RoutePeer, +} from "openclaw/plugin-sdk"; import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { buildAccountScopedAllowlistConfigEditor, @@ -196,6 +201,102 @@ function parseDiscordExplicitTarget(raw: string) { } } +function normalizeOutboundThreadId(value?: string | number | null): string | undefined { + if (value == null) { + return undefined; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return undefined; + } + return String(Math.trunc(value)); + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function buildDiscordBaseSessionKey(params: { + cfg: OpenClawConfig; + agentId: string; + accountId?: string | null; + peer: RoutePeer; +}) { + return buildAgentSessionKey({ + agentId: params.agentId, + channel: "discord", + accountId: params.accountId, + peer: params.peer, + dmScope: params.cfg.session?.dmScope ?? "main", + identityLinks: params.cfg.session?.identityLinks, + }); +} + +function resolveDiscordOutboundTargetKindHint(params: { + target: string; + resolvedTarget?: { kind: string }; +}): "user" | "channel" | undefined { + const resolvedKind = params.resolvedTarget?.kind; + if (resolvedKind === "user") { + return "user"; + } + if (resolvedKind === "group" || resolvedKind === "channel") { + return "channel"; + } + + const target = params.target.trim(); + if (/^channel:/i.test(target)) { + return "channel"; + } + if (/^(user:|discord:|@|<@!?)/i.test(target)) { + return "user"; + } + return undefined; +} + +function resolveDiscordOutboundSessionRoute(params: { + cfg: OpenClawConfig; + agentId: string; + accountId?: string | null; + target: string; + resolvedTarget?: { kind: string }; + replyToId?: string | null; + threadId?: string | number | null; +}) { + const parsed = parseDiscordTarget(params.target, { + defaultKind: resolveDiscordOutboundTargetKindHint(params), + }); + if (!parsed) { + return null; + } + const isDm = parsed.kind === "user"; + const peer: RoutePeer = { + kind: isDm ? "direct" : "channel", + id: parsed.id, + }; + const baseSessionKey = buildDiscordBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + accountId: params.accountId, + peer, + }); + const explicitThreadId = normalizeOutboundThreadId(params.threadId); + const threadCandidate = explicitThreadId ?? normalizeOutboundThreadId(params.replyToId); + const threadKeys = resolveThreadSessionKeys({ + baseSessionKey, + threadId: threadCandidate, + useSuffix: false, + }); + return { + sessionKey: threadKeys.sessionKey, + baseSessionKey, + peer, + chatType: isDm ? ("direct" as const) : ("channel" as const), + from: isDm ? `discord:${parsed.id}` : `discord:channel:${parsed.id}`, + to: isDm ? `user:${parsed.id}` : `channel:${parsed.id}`, + threadId: explicitThreadId ?? undefined, + }; +} + const discordConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, @@ -341,6 +442,7 @@ export const discordPlugin: ChannelPlugin = { parseExplicitTarget: ({ raw }) => parseDiscordExplicitTarget(raw), inferTargetChatType: ({ to }) => parseDiscordExplicitTarget(to)?.chatType, buildCrossContextComponents: buildDiscordCrossContextComponents, + resolveOutboundSessionRoute: (params) => resolveDiscordOutboundSessionRoute(params), targetResolver: { looksLikeId: looksLikeDiscordTargetId, hint: "", diff --git a/extensions/discord/src/draft-chunking.ts b/extensions/discord/src/draft-chunking.ts index ce4048379d1..1d56841577a 100644 --- a/extensions/discord/src/draft-chunking.ts +++ b/extensions/discord/src/draft-chunking.ts @@ -1,8 +1,8 @@ import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; -import { getChannelDock } from "../../../src/channels/dock.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; import { normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DISCORD_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js"; const DEFAULT_DISCORD_DRAFT_STREAM_MIN = 200; const DEFAULT_DISCORD_DRAFT_STREAM_MAX = 800; @@ -15,9 +15,8 @@ export function resolveDiscordDraftStreamingChunking( maxChars: number; breakPreference: "paragraph" | "newline" | "sentence"; } { - const providerChunkLimit = getChannelDock("discord")?.outbound?.textChunkLimit; const textLimit = resolveTextChunkLimit(cfg, "discord", accountId, { - fallbackLimit: providerChunkLimit, + fallbackLimit: DISCORD_TEXT_CHUNK_LIMIT, }); const normalizedAccountId = normalizeAccountId(accountId); const accountCfg = resolveAccountEntry(cfg?.channels?.discord?.accounts, normalizedAccountId); diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index 1c6e0111869..bc2f5f8c2d1 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -18,6 +18,8 @@ import { } from "./send.js"; import { buildDiscordInteractiveComponents } from "./shared-interactive.js"; +export const DISCORD_TEXT_CHUNK_LIMIT = 2000; + function resolveDiscordOutboundTarget(params: { to: string; threadId?: string | number | null; @@ -86,7 +88,7 @@ async function maybeSendDiscordWebhookText(params: { export const discordOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: null, - textChunkLimit: 2000, + textChunkLimit: DISCORD_TEXT_CHUNK_LIMIT, pollMaxOptions: 10, resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), sendPayload: async (ctx) => { diff --git a/extensions/googlechat/index.ts b/extensions/googlechat/index.ts index e218a15c8de..892694f93b4 100644 --- a/extensions/googlechat/index.ts +++ b/extensions/googlechat/index.ts @@ -1,6 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/googlechat"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/googlechat"; -import { googlechatDock, googlechatPlugin } from "./src/channel.js"; +import { googlechatPlugin } from "./src/channel.js"; import { setGoogleChatRuntime } from "./src/runtime.js"; const plugin = { @@ -10,7 +10,7 @@ const plugin = { configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { setGoogleChatRuntime(api.runtime); - api.registerChannel({ plugin: googlechatPlugin, dock: googlechatDock }); + api.registerChannel(googlechatPlugin); }, }; diff --git a/extensions/googlechat/setup-entry.ts b/extensions/googlechat/setup-entry.ts index 7d80304ccf3..be33127799f 100644 --- a/extensions/googlechat/setup-entry.ts +++ b/extensions/googlechat/setup-entry.ts @@ -1,6 +1,5 @@ -import { googlechatDock, googlechatPlugin } from "./src/channel.js"; +import { googlechatPlugin } from "./src/channel.js"; export default { plugin: googlechatPlugin, - dock: googlechatDock, }; diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 5d2c9d86748..bd06b33f8df 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -19,7 +19,6 @@ import { resolveChannelMediaMaxBytes, resolveGoogleChatGroupRequireMention, runPassiveAccountLifecycle, - type ChannelDock, type ChannelMessageActionAdapter, type ChannelPlugin, type ChannelStatusIssue, @@ -94,33 +93,6 @@ const resolveGoogleChatDmPolicy = createScopedDmSecurityResolver formatAllowFromEntry(raw), }); -export const googlechatDock: ChannelDock = { - id: "googlechat", - capabilities: { - chatTypes: ["direct", "group", "thread"], - reactions: true, - media: true, - threads: true, - blockStreaming: true, - }, - outbound: { textChunkLimit: 4000 }, - config: googleChatConfigAccessors, - groups: { - resolveRequireMention: resolveGoogleChatGroupRequireMention, - }, - threading: { - resolveReplyToMode: ({ cfg }) => cfg.channels?.["googlechat"]?.replyToMode ?? "off", - buildToolContext: ({ context, hasRepliedRef }) => { - const threadId = context.MessageThreadId ?? context.ReplyToId; - return { - currentChannelId: context.To?.trim() || undefined, - currentThreadTs: threadId != null ? String(threadId) : undefined, - hasRepliedRef, - }; - }, - }, -}; - const googlechatActions: ChannelMessageActionAdapter = { listActions: (ctx) => googlechatMessageActions.listActions?.(ctx) ?? [], extractToolSend: (ctx) => googlechatMessageActions.extractToolSend?.(ctx) ?? null, diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 295f16970ad..a4767706757 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,3 +1,4 @@ +import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk"; import { buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, @@ -30,6 +31,7 @@ import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getIMessageRuntime } from "./runtime.js"; import { createIMessageSetupWizardProxy, imessageSetupAdapter } from "./setup-core.js"; +import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js"; const meta = getChatChannelMeta("imessage"); @@ -75,6 +77,83 @@ async function sendIMessageOutbound(params: { }); } +function buildIMessageBaseSessionKey(params: { + cfg: Parameters[0]["cfg"]; + agentId: string; + accountId?: string | null; + peer: RoutePeer; +}) { + return buildAgentSessionKey({ + agentId: params.agentId, + channel: "imessage", + accountId: params.accountId, + peer: params.peer, + dmScope: params.cfg.session?.dmScope ?? "main", + identityLinks: params.cfg.session?.identityLinks, + }); +} + +function resolveIMessageOutboundSessionRoute(params: { + cfg: Parameters[0]["cfg"]; + agentId: string; + accountId?: string | null; + target: string; +}) { + const parsed = parseIMessageTarget(params.target); + if (parsed.kind === "handle") { + const handle = normalizeIMessageHandle(parsed.to); + if (!handle) { + return null; + } + const peer: RoutePeer = { kind: "direct", id: handle }; + const baseSessionKey = buildIMessageBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + accountId: params.accountId, + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: "direct" as const, + from: `imessage:${handle}`, + to: `imessage:${handle}`, + }; + } + + const peerId = + parsed.kind === "chat_id" + ? String(parsed.chatId) + : parsed.kind === "chat_guid" + ? parsed.chatGuid + : parsed.chatIdentifier; + if (!peerId) { + return null; + } + const peer: RoutePeer = { kind: "group", id: peerId }; + const baseSessionKey = buildIMessageBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + accountId: params.accountId, + peer, + }); + const toPrefix = + parsed.kind === "chat_id" + ? "chat_id" + : parsed.kind === "chat_guid" + ? "chat_guid" + : "chat_identifier"; + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: "group" as const, + from: `imessage:group:${peerId}`, + to: `${toPrefix}:${peerId}`, + }; +} + export const imessagePlugin: ChannelPlugin = { id: "imessage", meta: { @@ -176,6 +255,7 @@ export const imessagePlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeIMessageMessagingTarget, + resolveOutboundSessionRoute: (params) => resolveIMessageOutboundSessionRoute(params), targetResolver: { looksLikeId: looksLikeIMessageTargetId, hint: "", diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 7567d68d4fa..2d7e1983972 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,3 +1,4 @@ +import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk"; import { buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, @@ -31,6 +32,12 @@ 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 { + looksLikeUuid, + resolveSignalPeerId, + resolveSignalRecipient, + resolveSignalSender, +} from "./identity.js"; import type { SignalProbe } from "./probe.js"; import { getSignalRuntime } from "./runtime.js"; import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js"; @@ -138,6 +145,88 @@ function parseSignalExplicitTarget(raw: string) { }; } +function buildSignalBaseSessionKey(params: { + cfg: Parameters[0]["cfg"]; + agentId: string; + accountId?: string | null; + peer: RoutePeer; +}) { + return buildAgentSessionKey({ + agentId: params.agentId, + channel: "signal", + accountId: params.accountId, + peer: params.peer, + dmScope: params.cfg.session?.dmScope ?? "main", + identityLinks: params.cfg.session?.identityLinks, + }); +} + +function resolveSignalOutboundSessionRoute(params: { + cfg: Parameters[0]["cfg"]; + agentId: string; + accountId?: string | null; + target: string; +}) { + const stripped = params.target.replace(/^signal:/i, "").trim(); + const lowered = stripped.toLowerCase(); + if (lowered.startsWith("group:")) { + const groupId = stripped.slice("group:".length).trim(); + if (!groupId) { + return null; + } + const peer: RoutePeer = { kind: "group", id: groupId }; + const baseSessionKey = buildSignalBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + accountId: params.accountId, + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: "group" as const, + from: `group:${groupId}`, + to: `group:${groupId}`, + }; + } + + let recipient = stripped.trim(); + if (lowered.startsWith("username:")) { + recipient = stripped.slice("username:".length).trim(); + } else if (lowered.startsWith("u:")) { + recipient = stripped.slice("u:".length).trim(); + } + if (!recipient) { + return null; + } + + const uuidCandidate = recipient.toLowerCase().startsWith("uuid:") + ? recipient.slice("uuid:".length) + : recipient; + const sender = resolveSignalSender({ + sourceUuid: looksLikeUuid(uuidCandidate) ? uuidCandidate : null, + sourceNumber: looksLikeUuid(uuidCandidate) ? null : recipient, + }); + const peerId = sender ? resolveSignalPeerId(sender) : recipient; + const displayRecipient = sender ? resolveSignalRecipient(sender) : recipient; + const peer: RoutePeer = { kind: "direct", id: peerId }; + const baseSessionKey = buildSignalBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + accountId: params.accountId, + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: "direct" as const, + from: `signal:${displayRecipient}`, + to: `signal:${displayRecipient}`, + }; +} + async function sendFormattedSignalText(ctx: { cfg: Parameters[0]["cfg"]; to: string; @@ -324,6 +413,7 @@ export const signalPlugin: ChannelPlugin = { normalizeTarget: normalizeSignalMessagingTarget, parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw), inferTargetChatType: ({ to }) => inferSignalTargetChatType(to), + resolveOutboundSessionRoute: (params) => resolveSignalOutboundSessionRoute(params), targetResolver: { looksLikeId: looksLikeSignalTargetId, hint: "", diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 33322732236..32d362be5e6 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -1,3 +1,8 @@ +import { + buildAgentSessionKey, + resolveThreadSessionKeys, + type RoutePeer, +} from "openclaw/plugin-sdk"; import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { buildAccountScopedAllowlistConfigEditor, @@ -33,11 +38,14 @@ import { buildSlackThreadingToolContext, SlackConfigSchema, type ChannelPlugin, + type OpenClawConfig, type ResolvedSlackAccount, } from "openclaw/plugin-sdk/slack"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { parseSlackBlocksInput } from "./blocks-input.js"; +import { createSlackWebClient } from "./client.js"; +import { normalizeAllowListLower } from "./monitor/allow-list.js"; import type { SlackProbe } from "./probe.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; import { getSlackRuntime } from "./runtime.js"; @@ -46,6 +54,7 @@ import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js" import { parseSlackTarget } from "./targets.js"; const meta = getChatChannelMeta("slack"); +const SLACK_CHANNEL_TYPE_CACHE = new Map(); async function loadSlackChannelRuntime() { return await import("./channel.runtime.js"); @@ -142,6 +151,157 @@ function parseSlackExplicitTarget(raw: string) { }; } +function normalizeOutboundThreadId(value?: string | number | null): string | undefined { + if (value == null) { + return undefined; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return undefined; + } + return String(Math.trunc(value)); + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function buildSlackBaseSessionKey(params: { + cfg: OpenClawConfig; + agentId: string; + accountId?: string | null; + peer: RoutePeer; +}) { + return buildAgentSessionKey({ + agentId: params.agentId, + channel: "slack", + accountId: params.accountId, + peer: params.peer, + dmScope: params.cfg.session?.dmScope ?? "main", + identityLinks: params.cfg.session?.identityLinks, + }); +} + +async function resolveSlackChannelType(params: { + cfg: OpenClawConfig; + accountId?: string | null; + channelId: string; +}): Promise<"channel" | "group" | "dm" | "unknown"> { + const channelId = params.channelId.trim(); + if (!channelId) { + return "unknown"; + } + const cacheKey = `${params.accountId ?? "default"}:${channelId}`; + const cached = SLACK_CHANNEL_TYPE_CACHE.get(cacheKey); + if (cached) { + return cached; + } + + const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); + const groupChannels = normalizeAllowListLower(account.dm?.groupChannels); + const channelIdLower = channelId.toLowerCase(); + if ( + groupChannels.includes(channelIdLower) || + groupChannels.includes(`slack:${channelIdLower}`) || + groupChannels.includes(`channel:${channelIdLower}`) || + groupChannels.includes(`group:${channelIdLower}`) || + groupChannels.includes(`mpim:${channelIdLower}`) + ) { + SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "group"); + return "group"; + } + + const channelKeys = Object.keys(account.channels ?? {}); + if ( + channelKeys.some((key) => { + const normalized = key.trim().toLowerCase(); + return ( + normalized === channelIdLower || + normalized === `channel:${channelIdLower}` || + normalized.replace(/^#/, "") === channelIdLower + ); + }) + ) { + SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "channel"); + return "channel"; + } + + const token = account.botToken?.trim() || account.config.userToken?.trim() || ""; + if (!token) { + SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "unknown"); + return "unknown"; + } + + try { + const client = createSlackWebClient(token); + const info = await client.conversations.info({ channel: channelId }); + const channel = info.channel as { is_im?: boolean; is_mpim?: boolean } | undefined; + const type = channel?.is_im ? "dm" : channel?.is_mpim ? "group" : "channel"; + SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, type); + return type; + } catch { + SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "unknown"); + return "unknown"; + } +} + +async function resolveSlackOutboundSessionRoute(params: { + cfg: OpenClawConfig; + agentId: string; + accountId?: string | null; + target: string; + replyToId?: string | null; + threadId?: string | number | null; +}) { + const parsed = parseSlackTarget(params.target, { defaultKind: "channel" }); + if (!parsed) { + return null; + } + const isDm = parsed.kind === "user"; + let peerKind: "direct" | "channel" | "group" = isDm ? "direct" : "channel"; + if (!isDm && /^G/i.test(parsed.id)) { + const channelType = await resolveSlackChannelType({ + cfg: params.cfg, + accountId: params.accountId, + channelId: parsed.id, + }); + if (channelType === "group") { + peerKind = "group"; + } + if (channelType === "dm") { + peerKind = "direct"; + } + } + const peer: RoutePeer = { + kind: peerKind, + id: parsed.id, + }; + const baseSessionKey = buildSlackBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + accountId: params.accountId, + peer, + }); + const threadId = normalizeOutboundThreadId(params.threadId ?? params.replyToId); + const threadKeys = resolveThreadSessionKeys({ + baseSessionKey, + threadId, + }); + return { + sessionKey: threadKeys.sessionKey, + baseSessionKey, + peer, + chatType: peerKind === "direct" ? ("direct" as const) : ("channel" as const), + from: + peerKind === "direct" + ? `slack:${parsed.id}` + : peerKind === "group" + ? `slack:group:${parsed.id}` + : `slack:channel:${parsed.id}`, + to: peerKind === "direct" ? `user:${parsed.id}` : `channel:${parsed.id}`, + threadId, + }; +} + function formatSlackScopeDiagnostic(params: { tokenType: "bot" | "user"; result: Awaited>; @@ -362,6 +522,7 @@ export const slackPlugin: ChannelPlugin = { normalizeTarget: normalizeSlackMessagingTarget, parseExplicitTarget: ({ raw }) => parseSlackExplicitTarget(raw), inferTargetChatType: ({ to }) => parseSlackExplicitTarget(to)?.chatType, + resolveOutboundSessionRoute: async (params) => await resolveSlackOutboundSessionRoute(params), enableInteractiveReplies: ({ cfg, accountId }) => isSlackInteractiveRepliesEnabled({ cfg, accountId }), hasStructuredReplyPayload: ({ payload }) => { diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index dda83e3f521..1b30d43a7e0 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,3 +1,8 @@ +import { + buildAgentSessionKey, + resolveThreadSessionKeys, + type RoutePeer, +} from "openclaw/plugin-sdk"; import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { buildAccountScopedAllowlistConfigEditor, @@ -47,6 +52,7 @@ import { } from "../../../src/infra/outbound/send-deps.js"; import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; +import { buildTelegramGroupPeerId } from "./bot/helpers.js"; import { isTelegramExecApprovalClientEnabled, resolveTelegramExecApprovalTarget, @@ -214,6 +220,87 @@ function parseTelegramExplicitTarget(raw: string) { }; } +function normalizeOutboundThreadId(value?: string | number | null): string | undefined { + if (value == null) { + return undefined; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return undefined; + } + return String(Math.trunc(value)); + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function buildTelegramBaseSessionKey(params: { + cfg: OpenClawConfig; + agentId: string; + accountId?: string | null; + peer: RoutePeer; +}) { + return buildAgentSessionKey({ + agentId: params.agentId, + channel: "telegram", + accountId: params.accountId, + peer: params.peer, + dmScope: params.cfg.session?.dmScope ?? "main", + identityLinks: params.cfg.session?.identityLinks, + }); +} + +function resolveTelegramOutboundSessionRoute(params: { + cfg: OpenClawConfig; + agentId: string; + accountId?: string | null; + target: string; + resolvedTarget?: { kind: string }; + threadId?: string | number | null; +}) { + const parsed = parseTelegramTarget(params.target); + const chatId = parsed.chatId.trim(); + if (!chatId) { + return null; + } + const fallbackThreadId = normalizeOutboundThreadId(params.threadId); + const resolvedThreadId = parsed.messageThreadId ?? parseTelegramThreadId(fallbackThreadId); + const isGroup = + parsed.chatType === "group" || + (parsed.chatType === "unknown" && + params.resolvedTarget?.kind && + params.resolvedTarget.kind !== "user"); + const peerId = + isGroup && resolvedThreadId ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : chatId; + const peer: RoutePeer = { + kind: isGroup ? "group" : "direct", + id: peerId, + }; + const baseSessionKey = buildTelegramBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + accountId: params.accountId, + peer, + }); + const threadKeys = + resolvedThreadId && !isGroup + ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(resolvedThreadId) }) + : null; + return { + sessionKey: threadKeys?.sessionKey ?? baseSessionKey, + baseSessionKey, + peer, + chatType: isGroup ? ("group" as const) : ("direct" as const), + from: isGroup + ? `telegram:group:${peerId}` + : resolvedThreadId + ? `telegram:${chatId}:topic:${resolvedThreadId}` + : `telegram:${chatId}`, + to: `telegram:${chatId}`, + threadId: resolvedThreadId, + }; +} + function hasTelegramExecApprovalDmRoute(cfg: OpenClawConfig): boolean { return listTelegramAccountIds(cfg).some((accountId) => { if (!isTelegramExecApprovalClientEnabled({ cfg, accountId })) { @@ -418,6 +505,7 @@ export const telegramPlugin: ChannelPlugin parseTelegramExplicitTarget(raw), inferTargetChatType: ({ to }) => parseTelegramExplicitTarget(to).chatType, + resolveOutboundSessionRoute: (params) => resolveTelegramOutboundSessionRoute(params), targetResolver: { looksLikeId: looksLikeTelegramTargetId, hint: "", diff --git a/extensions/telegram/src/draft-chunking.ts b/extensions/telegram/src/draft-chunking.ts index f907faf02f8..951fbb41951 100644 --- a/extensions/telegram/src/draft-chunking.ts +++ b/extensions/telegram/src/draft-chunking.ts @@ -1,8 +1,8 @@ import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; -import { getChannelDock } from "../../../src/channels/dock.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; import { normalizeAccountId } from "../../../src/routing/session-key.js"; +import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js"; const DEFAULT_TELEGRAM_DRAFT_STREAM_MIN = 200; const DEFAULT_TELEGRAM_DRAFT_STREAM_MAX = 800; @@ -15,9 +15,8 @@ export function resolveTelegramDraftStreamingChunking( maxChars: number; breakPreference: "paragraph" | "newline" | "sentence"; } { - const providerChunkLimit = getChannelDock("telegram")?.outbound?.textChunkLimit; const textLimit = resolveTextChunkLimit(cfg, "telegram", accountId, { - fallbackLimit: providerChunkLimit, + fallbackLimit: TELEGRAM_TEXT_CHUNK_LIMIT, }); const normalizedAccountId = normalizeAccountId(accountId); const accountCfg = resolveAccountEntry(cfg?.channels?.telegram?.accounts, normalizedAccountId); diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 0ab050bbd06..25bd2329ed7 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -15,6 +15,8 @@ import { markdownToTelegramHtmlChunks } from "./format.js"; import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js"; import { sendMessageTelegram } from "./send.js"; +export const TELEGRAM_TEXT_CHUNK_LIMIT = 4000; + type TelegramSendFn = typeof sendMessageTelegram; type TelegramSendOpts = Parameters[2]; @@ -101,7 +103,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: markdownToTelegramHtmlChunks, chunkerMode: "markdown", - textChunkLimit: 4000, + textChunkLimit: TELEGRAM_TEXT_CHUNK_LIMIT, shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData), resolveEffectiveTextChunkLimit: ({ fallbackLimit }) => typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096, diff --git a/extensions/zalo/index.ts b/extensions/zalo/index.ts index 3028b8b492f..ef62ee6e560 100644 --- a/extensions/zalo/index.ts +++ b/extensions/zalo/index.ts @@ -1,6 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/zalo"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/zalo"; -import { zaloDock, zaloPlugin } from "./src/channel.js"; +import { zaloPlugin } from "./src/channel.js"; import { setZaloRuntime } from "./src/runtime.js"; const plugin = { @@ -10,7 +10,7 @@ const plugin = { configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { setZaloRuntime(api.runtime); - api.registerChannel({ plugin: zaloPlugin, dock: zaloDock }); + api.registerChannel(zaloPlugin); }, }; diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 69f99c69e3a..32ceeeff110 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -8,7 +8,6 @@ import { } from "openclaw/plugin-sdk/compat"; import type { ChannelAccountSnapshot, - ChannelDock, ChannelPlugin, OpenClawConfig, } from "openclaw/plugin-sdk/zalo"; @@ -64,28 +63,6 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined { return trimmed.replace(/^(zalo|zl):/i, ""); } -export const zaloDock: ChannelDock = { - id: "zalo", - capabilities: { - chatTypes: ["direct", "group"], - media: true, - blockStreaming: true, - }, - outbound: { textChunkLimit: 2000 }, - config: { - resolveAllowFrom: ({ cfg, accountId }) => - mapAllowFromEntries(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom), - formatAllowFrom: ({ allowFrom }) => - formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }), - }, - groups: { - resolveRequireMention: () => true, - }, - threading: { - resolveReplyToMode: () => "off", - }, -}; - export const zaloPlugin: ChannelPlugin = { id: "zalo", meta, diff --git a/extensions/zalouser/index.ts b/extensions/zalouser/index.ts index 747a7e26531..8d470b043e3 100644 --- a/extensions/zalouser/index.ts +++ b/extensions/zalouser/index.ts @@ -1,6 +1,6 @@ import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/zalouser"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/zalouser"; -import { zalouserDock, zalouserPlugin } from "./src/channel.js"; +import { zalouserPlugin } from "./src/channel.js"; import { setZalouserRuntime } from "./src/runtime.js"; import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js"; @@ -11,7 +11,7 @@ const plugin = { configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { setZalouserRuntime(api.runtime); - api.registerChannel({ plugin: zalouserPlugin, dock: zalouserDock }); + api.registerChannel(zalouserPlugin); if (api.registrationMode !== "full") { return; } diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 46dbb2c9fee..7e79b186c3d 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -6,7 +6,6 @@ import { import type { ChannelAccountSnapshot, ChannelDirectoryEntry, - ChannelDock, ChannelGroupContext, ChannelMessageActionAdapter, ChannelPlugin, @@ -67,6 +66,8 @@ const meta = { quickstartAllowFrom: true, }; +const ZALOUSER_TEXT_CHUNK_LIMIT = 2000; + function stripZalouserTargetPrefix(raw: string): string { return raw .trim() @@ -172,7 +173,7 @@ function resolveZalouserOutboundChunkMode(cfg: OpenClawConfig, accountId?: strin function resolveZalouserOutboundTextChunkLimit(cfg: OpenClawConfig, accountId?: string) { return getZalouserRuntime().channel.text.resolveTextChunkLimit(cfg, "zalouser", accountId, { - fallbackLimit: zalouserDock.outbound?.textChunkLimit ?? 2000, + fallbackLimit: ZALOUSER_TEXT_CHUNK_LIMIT, }); } @@ -304,29 +305,6 @@ const zalouserMessageActions: ChannelMessageActionAdapter = { }, }; -export const zalouserDock: ChannelDock = { - id: "zalouser", - capabilities: { - chatTypes: ["direct", "group"], - media: true, - blockStreaming: true, - }, - outbound: { textChunkLimit: 2000 }, - config: { - resolveAllowFrom: ({ cfg, accountId }) => - mapAllowFromEntries(resolveZalouserAccountSync({ cfg: cfg, accountId }).config.allowFrom), - formatAllowFrom: ({ allowFrom }) => - formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }), - }, - groups: { - resolveRequireMention: resolveZalouserRequireMention, - resolveToolPolicy: resolveZalouserGroupToolPolicy, - }, - threading: { - resolveReplyToMode: () => "off", - }, -}; - export const zalouserPlugin: ChannelPlugin = { id: "zalouser", meta, diff --git a/src/channels/dock.test.ts b/src/channels/dock.test.ts deleted file mode 100644 index 99e3947be9b..00000000000 --- a/src/channels/dock.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { withEnv } from "../test-utils/env.js"; -import { getChannelDock } from "./dock.js"; - -function emptyConfig(): OpenClawConfig { - return {} as OpenClawConfig; -} - -describe("channels dock", () => { - it("telegram and googlechat threading contexts map thread ids consistently", () => { - const hasRepliedRef = { value: false }; - const telegramDock = getChannelDock("telegram"); - const googleChatDock = getChannelDock("googlechat"); - - const telegramContext = telegramDock?.threading?.buildToolContext?.({ - cfg: emptyConfig(), - context: { - To: " room-1 ", - MessageThreadId: 42, - ReplyToId: "fallback", - CurrentMessageId: "9001", - }, - hasRepliedRef, - }); - const googleChatContext = googleChatDock?.threading?.buildToolContext?.({ - cfg: emptyConfig(), - context: { To: " space-1 ", ReplyToId: "thread-abc" }, - hasRepliedRef, - }); - - expect(telegramContext).toEqual({ - currentChannelId: "room-1", - currentThreadTs: "42", - currentMessageId: "9001", - hasRepliedRef, - }); - expect(googleChatContext).toEqual({ - currentChannelId: "space-1", - currentThreadTs: "thread-abc", - hasRepliedRef, - }); - }); - - it("telegram threading does not treat ReplyToId as thread id in DMs", () => { - const hasRepliedRef = { value: false }; - const telegramDock = getChannelDock("telegram"); - const context = telegramDock?.threading?.buildToolContext?.({ - cfg: emptyConfig(), - context: { To: " dm-1 ", ReplyToId: "12345", CurrentMessageId: "12345" }, - hasRepliedRef, - }); - - expect(context).toEqual({ - currentChannelId: "dm-1", - currentThreadTs: undefined, - currentMessageId: "12345", - hasRepliedRef, - }); - }); - - it("irc resolveDefaultTo matches account id case-insensitively", () => { - const ircDock = getChannelDock("irc"); - const cfg = { - channels: { - irc: { - defaultTo: "#root", - accounts: { - Work: { defaultTo: "#work" }, - }, - }, - }, - } as unknown as OpenClawConfig; - - const accountDefault = ircDock?.config?.resolveDefaultTo?.({ cfg, accountId: "work" }); - const rootDefault = ircDock?.config?.resolveDefaultTo?.({ cfg, accountId: "missing" }); - - expect(accountDefault).toBe("#work"); - expect(rootDefault).toBe("#root"); - }); - - it("signal allowFrom formatter normalizes values and preserves wildcard", () => { - const signalDock = getChannelDock("signal"); - - const formatted = signalDock?.config?.formatAllowFrom?.({ - cfg: emptyConfig(), - allowFrom: [" signal:+14155550100 ", " * "], - }); - - expect(formatted).toEqual(["+14155550100", "*"]); - }); - - it("telegram allowFrom formatter trims, strips prefix, and lowercases", () => { - const telegramDock = getChannelDock("telegram"); - - const formatted = telegramDock?.config?.formatAllowFrom?.({ - cfg: emptyConfig(), - allowFrom: [" TG:User ", "telegram:Foo", " Plain "], - }); - - expect(formatted).toEqual(["user", "foo", "plain"]); - }); - - it("telegram dock config readers preserve omitted-account fallback semantics", () => { - withEnv({ TELEGRAM_BOT_TOKEN: "tok-env" }, () => { - const telegramDock = getChannelDock("telegram"); - const cfg = { - channels: { - telegram: { - allowFrom: ["top-owner"], - defaultTo: "@top-target", - accounts: { - work: { - botToken: "tok-work", - allowFrom: ["work-owner"], - defaultTo: "@work-target", - }, - }, - }, - }, - } as unknown as OpenClawConfig; - - expect(telegramDock?.config?.resolveAllowFrom?.({ cfg })).toEqual(["top-owner"]); - expect(telegramDock?.config?.resolveDefaultTo?.({ cfg })).toBe("@top-target"); - }); - }); - - it("slack dock config readers stay read-only when tokens are unresolved SecretRefs", () => { - const slackDock = getChannelDock("slack"); - const cfg = { - channels: { - slack: { - botToken: { - source: "env", - provider: "default", - id: "SLACK_BOT_TOKEN", - }, - appToken: { - source: "env", - provider: "default", - id: "SLACK_APP_TOKEN", - }, - defaultTo: "channel:C111", - dm: { allowFrom: ["U123"] }, - channels: { - C111: { requireMention: false }, - }, - replyToMode: "all", - }, - }, - } as unknown as OpenClawConfig; - - expect(slackDock?.config?.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual(["U123"]); - expect(slackDock?.config?.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe( - "channel:C111", - ); - expect( - slackDock?.threading?.resolveReplyToMode?.({ - cfg, - accountId: "default", - chatType: "channel", - }), - ).toBe("all"); - expect( - slackDock?.groups?.resolveRequireMention?.({ - cfg, - accountId: "default", - groupId: "C111", - }), - ).toBe(false); - }); - - it("dock config readers coerce numeric allowFrom/defaultTo entries through shared helpers", () => { - const telegramDock = getChannelDock("telegram"); - const signalDock = getChannelDock("signal"); - const cfg = { - channels: { - telegram: { - allowFrom: [12345], - defaultTo: 67890, - }, - signal: { - allowFrom: [14155550100], - defaultTo: 42, - }, - }, - } as unknown as OpenClawConfig; - - expect(telegramDock?.config?.resolveAllowFrom?.({ cfg })).toEqual(["12345"]); - expect(telegramDock?.config?.resolveDefaultTo?.({ cfg })).toBe("67890"); - expect(signalDock?.config?.resolveAllowFrom?.({ cfg })).toEqual(["14155550100"]); - expect(signalDock?.config?.resolveDefaultTo?.({ cfg })).toBe("42"); - }); -}); diff --git a/src/channels/dock.ts b/src/channels/dock.ts deleted file mode 100644 index 2e63583ca1b..00000000000 --- a/src/channels/dock.ts +++ /dev/null @@ -1,636 +0,0 @@ -import { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; -import { resolveSignalAccount } from "../../extensions/signal/src/accounts.js"; -import { inspectSlackAccount } from "../../extensions/slack/src/account-inspect.js"; -import { resolveSlackReplyToMode } from "../../extensions/slack/src/accounts.js"; -import { buildSlackThreadingToolContext } from "../../extensions/slack/src/threading-tool-context.js"; -import { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; -import { - resolveChannelGroupRequireMention, - resolveChannelGroupToolsPolicy, -} from "../config/group-policy.js"; -import { - formatAllowFromLowercase, - formatNormalizedAllowFromEntries, -} from "../plugin-sdk/allow-from.js"; -import { - mapAllowFromEntries, - resolveOptionalConfigString, - formatTrimmedAllowFromEntries, - formatWhatsAppConfigAllowFromEntries, - resolveIMessageConfigAllowFrom, - resolveIMessageConfigDefaultTo, - resolveWhatsAppConfigAllowFrom, - resolveWhatsAppConfigDefaultTo, -} from "../plugin-sdk/channel-config-helpers.js"; -import { requireActivePluginRegistry } from "../plugins/runtime.js"; -import { normalizeAccountId } from "../routing/session-key.js"; -import { normalizeE164 } from "../utils.js"; -import { - resolveDiscordGroupRequireMention, - resolveDiscordGroupToolPolicy, - resolveGoogleChatGroupRequireMention, - resolveGoogleChatGroupToolPolicy, - resolveIMessageGroupRequireMention, - resolveIMessageGroupToolPolicy, - resolveLineGroupRequireMention, - resolveLineGroupToolPolicy, - resolveSlackGroupRequireMention, - resolveSlackGroupToolPolicy, - resolveTelegramGroupRequireMention, - resolveTelegramGroupToolPolicy, - resolveWhatsAppGroupRequireMention, - resolveWhatsAppGroupToolPolicy, -} from "./plugins/group-mentions.js"; -import { normalizeSignalMessagingTarget } from "./plugins/normalize/signal.js"; -import type { - ChannelCapabilities, - ChannelCommandAdapter, - ChannelConfigAdapter, - ChannelElevatedAdapter, - ChannelGroupAdapter, - ChannelId, - ChannelAgentPromptAdapter, - ChannelMentionAdapter, - ChannelPlugin, - ChannelThreadingContext, - ChannelThreadingAdapter, - ChannelThreadingToolContext, -} from "./plugins/types.js"; -import { - resolveWhatsAppGroupIntroHint, - resolveWhatsAppMentionStripRegexes, -} from "./plugins/whatsapp-shared.js"; -import { CHAT_CHANNEL_ORDER, type ChatChannelId, getChatChannelMeta } from "./registry.js"; - -export type ChannelDock = { - id: ChannelId; - capabilities: ChannelCapabilities; - commands?: ChannelCommandAdapter; - outbound?: { - textChunkLimit?: number; - }; - streaming?: ChannelDockStreaming; - elevated?: ChannelElevatedAdapter; - config?: Pick< - ChannelConfigAdapter, - "resolveAllowFrom" | "formatAllowFrom" | "resolveDefaultTo" - >; - groups?: ChannelGroupAdapter; - mentions?: ChannelMentionAdapter; - threading?: ChannelThreadingAdapter; - agentPrompt?: ChannelAgentPromptAdapter; -}; - -type ChannelDockStreaming = { - blockStreamingCoalesceDefaults?: { - minChars?: number; - idleMs?: number; - }; -}; - -const DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000 = { textChunkLimit: 4000 }; - -const DEFAULT_BLOCK_STREAMING_COALESCE = { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, -}; - -function formatAllowFromWithReplacements( - allowFrom: Array, - replacements: RegExp[], -): string[] { - return formatNormalizedAllowFromEntries({ - allowFrom, - normalizeEntry: (entry) => { - let normalized = entry; - for (const replacement of replacements) { - normalized = normalized.replace(replacement, ""); - } - return normalized.toLowerCase(); - }, - }); -} - -const formatDiscordAllowFrom = (allowFrom: Array) => - allowFrom - .map((entry) => - String(entry) - .trim() - .replace(/^<@!?/, "") - .replace(/>$/, "") - .replace(/^discord:/i, "") - .replace(/^user:/i, "") - .replace(/^pk:/i, "") - .trim() - .toLowerCase(), - ) - .filter(Boolean); - -function resolveDirectOrGroupChannelId(context: ChannelThreadingContext): string | undefined { - const isDirect = context.ChatType?.toLowerCase() === "direct"; - return (isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined; -} - -function buildSignalThreadToolContext(params: { - context: ChannelThreadingContext; - hasRepliedRef: ChannelThreadingToolContext["hasRepliedRef"]; -}): ChannelThreadingToolContext { - const currentChannelIdRaw = resolveDirectOrGroupChannelId(params.context); - const currentChannelId = currentChannelIdRaw - ? (normalizeSignalMessagingTarget(currentChannelIdRaw) ?? currentChannelIdRaw.trim()) - : undefined; - return { - currentChannelId, - currentThreadTs: params.context.ReplyToId, - hasRepliedRef: params.hasRepliedRef, - }; -} - -function buildIMessageThreadToolContext(params: { - context: ChannelThreadingContext; - hasRepliedRef: ChannelThreadingToolContext["hasRepliedRef"]; -}): ChannelThreadingToolContext { - return { - currentChannelId: resolveDirectOrGroupChannelId(params.context), - currentThreadTs: params.context.ReplyToId, - hasRepliedRef: params.hasRepliedRef, - }; -} - -function buildThreadToolContextFromMessageThreadOrReply(params: { - context: ChannelThreadingContext; - hasRepliedRef: ChannelThreadingToolContext["hasRepliedRef"]; -}): ChannelThreadingToolContext { - const threadId = params.context.MessageThreadId ?? params.context.ReplyToId; - return { - currentChannelId: params.context.To?.trim() || undefined, - currentThreadTs: threadId != null ? String(threadId) : undefined, - hasRepliedRef: params.hasRepliedRef, - }; -} - -function resolveCaseInsensitiveAccount( - accounts: Record | undefined, - accountId?: string | null, -): T | undefined { - if (!accounts) { - return undefined; - } - const normalized = normalizeAccountId(accountId); - return ( - accounts[normalized] ?? - accounts[ - Object.keys(accounts).find((key) => key.toLowerCase() === normalized.toLowerCase()) ?? "" - ] - ); -} - -function resolveDefaultToCaseInsensitiveAccount(params: { - channel?: - | { - accounts?: Record; - defaultTo?: string; - } - | undefined; - accountId?: string | null; -}): string | undefined { - const account = resolveCaseInsensitiveAccount(params.channel?.accounts, params.accountId); - return (account?.defaultTo ?? params.channel?.defaultTo)?.trim() || undefined; -} - -function resolveChannelDefaultTo( - channel: - | { - accounts?: Record; - defaultTo?: string; - } - | undefined, - accountId?: string | null, -): string | undefined { - return resolveDefaultToCaseInsensitiveAccount({ channel, accountId }); -} - -type CaseInsensitiveDefaultToChannel = { - accounts?: Record; - defaultTo?: string; -}; - -type CaseInsensitiveDefaultToChannels = Partial< - Record<"irc" | "googlechat", CaseInsensitiveDefaultToChannel> ->; - -function resolveNamedChannelDefaultTo(params: { - channels?: CaseInsensitiveDefaultToChannels; - channelId: keyof CaseInsensitiveDefaultToChannels; - accountId?: string | null; -}): string | undefined { - return resolveChannelDefaultTo(params.channels?.[params.channelId], params.accountId); -} -// Channel docks: lightweight channel metadata/behavior for shared code paths. -// -// Rules: -// - keep this module *light* (no monitors, probes, puppeteer/web login, etc) -// - OK: config readers, allowFrom formatting, mention stripping patterns, threading defaults -// - shared code should import from here (and from `src/channels/registry.ts`), not from the plugins registry -// -// Adding a channel: -// - add a new entry to `DOCKS` -// - keep it cheap; push heavy logic into `src/channels/plugins/.ts` or channel modules -const DOCKS: Record = { - telegram: { - id: "telegram", - capabilities: { - chatTypes: ["direct", "group", "channel", "thread"], - nativeCommands: true, - blockStreaming: true, - }, - outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000, - config: { - resolveAllowFrom: ({ cfg, accountId }) => - mapAllowFromEntries(inspectTelegramAccount({ cfg, accountId }).config.allowFrom), - formatAllowFrom: ({ allowFrom }) => - formatAllowFromLowercase({ - allowFrom, - stripPrefixRe: /^(telegram|tg):/i, - }), - resolveDefaultTo: ({ cfg, accountId }) => - resolveOptionalConfigString(inspectTelegramAccount({ cfg, accountId }).config.defaultTo), - }, - groups: { - resolveRequireMention: resolveTelegramGroupRequireMention, - resolveToolPolicy: resolveTelegramGroupToolPolicy, - }, - threading: { - resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off", - buildToolContext: ({ context, hasRepliedRef }) => { - // Telegram auto-threading should only use actual thread/topic IDs. - // ReplyToId is a message ID and causes invalid message_thread_id in DMs. - const threadId = context.MessageThreadId; - const rawCurrentMessageId = context.CurrentMessageId; - const currentMessageId = - typeof rawCurrentMessageId === "number" - ? rawCurrentMessageId - : rawCurrentMessageId?.trim() || undefined; - return { - currentChannelId: context.To?.trim() || undefined, - currentThreadTs: threadId != null ? String(threadId) : undefined, - currentMessageId, - hasRepliedRef, - }; - }, - }, - }, - whatsapp: { - id: "whatsapp", - capabilities: { - chatTypes: ["direct", "group"], - polls: true, - reactions: true, - media: true, - }, - commands: { - enforceOwnerForCommands: true, - skipWhenConfigEmpty: true, - }, - outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000, - config: { - resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), - formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), - }, - groups: { - resolveRequireMention: resolveWhatsAppGroupRequireMention, - resolveToolPolicy: resolveWhatsAppGroupToolPolicy, - resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, - }, - mentions: { - stripRegexes: ({ ctx }) => resolveWhatsAppMentionStripRegexes(ctx), - }, - threading: { - buildToolContext: ({ context, hasRepliedRef }) => { - const channelId = context.From?.trim() || context.To?.trim() || undefined; - return { - currentChannelId: channelId, - currentThreadTs: context.ReplyToId, - hasRepliedRef, - }; - }, - }, - }, - discord: { - id: "discord", - capabilities: { - chatTypes: ["direct", "channel", "thread"], - polls: true, - reactions: true, - media: true, - nativeCommands: true, - threads: true, - }, - outbound: { textChunkLimit: 2000 }, - streaming: DEFAULT_BLOCK_STREAMING_COALESCE, - elevated: { - allowFromFallback: ({ cfg }) => - cfg.channels?.discord?.allowFrom ?? cfg.channels?.discord?.dm?.allowFrom, - }, - config: { - resolveAllowFrom: ({ cfg, accountId }) => { - const account = inspectDiscordAccount({ cfg, accountId }); - return mapAllowFromEntries(account.config.allowFrom ?? account.config.dm?.allowFrom); - }, - formatAllowFrom: ({ allowFrom }) => formatDiscordAllowFrom(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => - resolveOptionalConfigString(inspectDiscordAccount({ cfg, accountId }).config.defaultTo), - }, - groups: { - resolveRequireMention: resolveDiscordGroupRequireMention, - resolveToolPolicy: resolveDiscordGroupToolPolicy, - }, - mentions: { - stripRegexes: () => [/<@!?\d+>/g], - }, - threading: { - resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off", - buildToolContext: ({ context, hasRepliedRef }) => ({ - currentChannelId: context.To?.trim() || undefined, - currentThreadTs: context.ReplyToId, - hasRepliedRef, - }), - }, - }, - irc: { - id: "irc", - capabilities: { - chatTypes: ["direct", "group"], - media: true, - blockStreaming: true, - }, - outbound: { textChunkLimit: 350 }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 300, idleMs: 1000 }, - }, - config: { - resolveAllowFrom: ({ cfg, accountId }) => { - const channel = cfg.channels?.irc; - const account = resolveCaseInsensitiveAccount(channel?.accounts, accountId); - return mapAllowFromEntries(account?.allowFrom ?? channel?.allowFrom); - }, - formatAllowFrom: ({ allowFrom }) => - formatAllowFromWithReplacements(allowFrom, [/^irc:/i, /^user:/i]), - resolveDefaultTo: ({ cfg, accountId }) => - resolveNamedChannelDefaultTo({ - channels: cfg.channels as CaseInsensitiveDefaultToChannels | undefined, - channelId: "irc", - accountId, - }), - }, - groups: { - resolveRequireMention: ({ cfg, accountId, groupId }) => { - if (!groupId) { - return true; - } - return resolveChannelGroupRequireMention({ - cfg, - channel: "irc", - groupId, - accountId, - groupIdCaseInsensitive: true, - }); - }, - resolveToolPolicy: ({ cfg, accountId, groupId, senderId, senderName, senderUsername }) => { - if (!groupId) { - return undefined; - } - // IRC supports per-channel tool policies. Prefer the shared resolver so - // toolsBySender is honored consistently across surfaces. - return resolveChannelGroupToolsPolicy({ - cfg, - channel: "irc", - groupId, - accountId, - groupIdCaseInsensitive: true, - senderId, - senderName, - senderUsername, - }); - }, - }, - }, - googlechat: { - id: "googlechat", - capabilities: { - chatTypes: ["direct", "group", "thread"], - reactions: true, - media: true, - threads: true, - blockStreaming: true, - }, - outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000, - config: { - resolveAllowFrom: ({ cfg, accountId }) => { - const channel = cfg.channels?.googlechat as - | { - accounts?: Record } }>; - dm?: { allowFrom?: Array }; - } - | undefined; - const account = resolveCaseInsensitiveAccount(channel?.accounts, accountId); - return mapAllowFromEntries(account?.dm?.allowFrom ?? channel?.dm?.allowFrom); - }, - formatAllowFrom: ({ allowFrom }) => - formatAllowFromWithReplacements(allowFrom, [ - /^(googlechat|google-chat|gchat):/i, - /^user:/i, - /^users\//i, - ]), - resolveDefaultTo: ({ cfg, accountId }) => - resolveNamedChannelDefaultTo({ - channels: cfg.channels as CaseInsensitiveDefaultToChannels | undefined, - channelId: "googlechat", - accountId, - }), - }, - groups: { - resolveRequireMention: resolveGoogleChatGroupRequireMention, - resolveToolPolicy: resolveGoogleChatGroupToolPolicy, - }, - threading: { - resolveReplyToMode: ({ cfg }) => cfg.channels?.googlechat?.replyToMode ?? "off", - buildToolContext: ({ context, hasRepliedRef }) => - buildThreadToolContextFromMessageThreadOrReply({ context, hasRepliedRef }), - }, - }, - slack: { - id: "slack", - capabilities: { - chatTypes: ["direct", "channel", "thread"], - reactions: true, - media: true, - nativeCommands: true, - threads: true, - }, - outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000, - streaming: DEFAULT_BLOCK_STREAMING_COALESCE, - config: { - resolveAllowFrom: ({ cfg, accountId }) => { - const account = inspectSlackAccount({ cfg, accountId }); - return mapAllowFromEntries(account.config.allowFrom ?? account.dm?.allowFrom); - }, - formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: ({ cfg, accountId }) => - resolveOptionalConfigString(inspectSlackAccount({ cfg, accountId }).config.defaultTo), - }, - groups: { - resolveRequireMention: resolveSlackGroupRequireMention, - resolveToolPolicy: resolveSlackGroupToolPolicy, - }, - mentions: { - stripRegexes: () => [/<@[^>]+>/g], - }, - threading: { - resolveReplyToMode: ({ cfg, accountId, chatType }) => - resolveSlackReplyToMode(inspectSlackAccount({ cfg, accountId }), chatType), - allowExplicitReplyTagsWhenOff: false, - buildToolContext: (params) => buildSlackThreadingToolContext(params), - }, - }, - signal: { - id: "signal", - capabilities: { - chatTypes: ["direct", "group"], - reactions: true, - media: true, - }, - outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000, - streaming: DEFAULT_BLOCK_STREAMING_COALESCE, - config: { - resolveAllowFrom: ({ cfg, accountId }) => - mapAllowFromEntries(resolveSignalAccount({ cfg, accountId }).config.allowFrom), - formatAllowFrom: ({ allowFrom }) => - formatNormalizedAllowFromEntries({ - allowFrom, - normalizeEntry: (entry) => - entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")), - }), - resolveDefaultTo: ({ cfg, accountId }) => - resolveOptionalConfigString(resolveSignalAccount({ cfg, accountId }).config.defaultTo), - }, - threading: { - buildToolContext: ({ context, hasRepliedRef }) => - buildSignalThreadToolContext({ context, hasRepliedRef }), - }, - }, - imessage: { - id: "imessage", - capabilities: { - chatTypes: ["direct", "group"], - reactions: true, - media: true, - }, - outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000, - config: { - resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }), - formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), - }, - groups: { - resolveRequireMention: resolveIMessageGroupRequireMention, - resolveToolPolicy: resolveIMessageGroupToolPolicy, - }, - threading: { - buildToolContext: ({ context, hasRepliedRef }) => - buildIMessageThreadToolContext({ context, hasRepliedRef }), - }, - }, - line: { - id: "line", - capabilities: { - chatTypes: ["direct", "group"], - media: true, - }, - outbound: { textChunkLimit: 5000 }, - groups: { - resolveRequireMention: resolveLineGroupRequireMention, - resolveToolPolicy: resolveLineGroupToolPolicy, - }, - }, -}; - -function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock { - return { - id: plugin.id, - capabilities: plugin.capabilities, - commands: plugin.commands, - outbound: plugin.outbound?.textChunkLimit - ? { textChunkLimit: plugin.outbound.textChunkLimit } - : undefined, - streaming: plugin.streaming - ? { blockStreamingCoalesceDefaults: plugin.streaming.blockStreamingCoalesceDefaults } - : undefined, - elevated: plugin.elevated, - config: plugin.config - ? { - resolveAllowFrom: plugin.config.resolveAllowFrom, - formatAllowFrom: plugin.config.formatAllowFrom, - resolveDefaultTo: plugin.config.resolveDefaultTo, - } - : undefined, - groups: plugin.groups, - mentions: plugin.mentions, - threading: plugin.threading, - agentPrompt: plugin.agentPrompt, - }; -} - -function listPluginDockEntries(): Array<{ id: ChannelId; dock: ChannelDock; order?: number }> { - const registry = requireActivePluginRegistry(); - const entries: Array<{ id: ChannelId; dock: ChannelDock; order?: number }> = []; - const seen = new Set(); - for (const entry of registry.channels) { - const plugin = entry.plugin; - const id = String(plugin.id).trim(); - if (!id || seen.has(id)) { - continue; - } - seen.add(id); - if (CHAT_CHANNEL_ORDER.includes(plugin.id as ChatChannelId)) { - continue; - } - const dock = entry.dock ?? buildDockFromPlugin(plugin); - entries.push({ id: plugin.id, dock, order: plugin.meta.order }); - } - return entries; -} - -export function listChannelDocks(): ChannelDock[] { - const baseEntries = CHAT_CHANNEL_ORDER.map((id) => ({ - id, - dock: DOCKS[id], - order: getChatChannelMeta(id).order, - })); - const pluginEntries = listPluginDockEntries(); - const combined = [...baseEntries, ...pluginEntries]; - combined.sort((a, b) => { - const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId); - const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId); - const orderA = a.order ?? (indexA === -1 ? 999 : indexA); - const orderB = b.order ?? (indexB === -1 ? 999 : indexB); - if (orderA !== orderB) { - return orderA - orderB; - } - return String(a.id).localeCompare(String(b.id)); - }); - return combined.map((entry) => entry.dock); -} - -export function getChannelDock(id: ChannelId): ChannelDock | undefined { - const core = DOCKS[id as ChatChannelId]; - if (core) { - return core; - } - const registry = requireActivePluginRegistry(); - const pluginEntry = registry.channels.find((entry) => entry.plugin.id === id); - if (!pluginEntry) { - return undefined; - } - return pluginEntry.dock ?? buildDockFromPlugin(pluginEntry.plugin); -} diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index 48a0f886208..fc79d26fa07 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -3,11 +3,13 @@ import type { OpenClawConfig } from "../../../config/config.js"; import type { ChannelAccountSnapshot, ChannelAccountState, - ChannelMessageCapability, ChannelSetupInput, } from "../types.core.js"; -import type { ChannelPlugin } from "../types.js"; -import type { ChannelMessageActionName } from "../types.js"; +import type { + ChannelMessageActionName, + ChannelMessageCapability, + ChannelPlugin, +} from "../types.js"; function sortStrings(values: readonly string[]) { return [...values].toSorted((left, right) => left.localeCompare(right)); diff --git a/src/channels/plugins/index.ts b/src/channels/plugins/index.ts index 43b0aa99452..f2a8aa56b95 100644 --- a/src/channels/plugins/index.ts +++ b/src/channels/plugins/index.ts @@ -8,8 +8,8 @@ import type { ChannelId, ChannelPlugin } from "./types.js"; // Channel plugins registry (runtime). // // This module is intentionally "heavy" (plugins may import channel monitors, web login, etc). -// Shared code paths (reply flow, command auth, sandbox explain) should depend on `src/channels/dock.ts` -// instead, and only call `getChannelPlugin()` at execution boundaries. +// Shared code paths should prefer narrower adapters and helpers instead of reaching into +// channel-specific runtime modules directly. // function dedupeChannels(channels: ChannelPlugin[]): ChannelPlugin[] { const seen = new Set(); diff --git a/src/infra/outbound/outbound-session.test.ts b/src/infra/outbound/outbound-session.test.ts index 17367f4a128..44a6df08251 100644 --- a/src/infra/outbound/outbound-session.test.ts +++ b/src/infra/outbound/outbound-session.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import { setDefaultChannelPluginRegistryForTests } from "../../commands/channel-test-helpers.js"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveOutboundSessionRoute } from "./outbound-session.js"; describe("resolveOutboundSessionRoute", () => { + beforeEach(() => { + setDefaultChannelPluginRegistryForTests(); + }); + const baseConfig = {} as OpenClawConfig; it("resolves provider-specific session routes", async () => { diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index 4298d6226ce..c8da99c5f66 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -1,25 +1,3 @@ -import { - parseDiscordTarget, - type DiscordTargetKind, -} from "../../../extensions/discord/src/targets.js"; -import { - parseIMessageTarget, - normalizeIMessageHandle, -} from "../../../extensions/imessage/src/targets.js"; -import { - looksLikeUuid, - resolveSignalPeerId, - resolveSignalRecipient, - resolveSignalSender, -} from "../../../extensions/signal/src/identity.js"; -import { resolveSlackAccount } from "../../../extensions/slack/src/accounts.js"; -import { createSlackWebClient } from "../../../extensions/slack/src/client.js"; -import { normalizeAllowListLower } from "../../../extensions/slack/src/monitor/allow-list.js"; -import { parseSlackTarget } from "../../../extensions/slack/src/targets.js"; -import { buildTelegramGroupPeerId } from "../../../extensions/telegram/src/bot/helpers.js"; -import { resolveTelegramTargetChatType } from "../../../extensions/telegram/src/inline-buttons.js"; -import { parseTelegramThreadId } from "../../../extensions/telegram/src/outbound-params.js"; -import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js"; import type { MsgContext } from "../../auto-reply/templating.js"; import type { ChatType } from "../../channels/chat-type.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; @@ -52,9 +30,6 @@ export type ResolveOutboundSessionRouteParams = { threadId?: string | number | null; }; -// Cache Slack channel type lookups to avoid repeated API calls. -const SLACK_CHANNEL_TYPE_CACHE = new Map(); - function normalizeThreadId(value?: string | number | null): string | undefined { if (value == null) { return undefined; @@ -124,238 +99,6 @@ function buildBaseSessionKey(params: { }); } -// Best-effort mpim detection: allowlist/config, then Slack API (if token available). -async function resolveSlackChannelType(params: { - cfg: OpenClawConfig; - accountId?: string | null; - channelId: string; -}): Promise<"channel" | "group" | "dm" | "unknown"> { - const channelId = params.channelId.trim(); - if (!channelId) { - return "unknown"; - } - const cached = SLACK_CHANNEL_TYPE_CACHE.get(`${params.accountId ?? "default"}:${channelId}`); - if (cached) { - return cached; - } - - const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); - const groupChannels = normalizeAllowListLower(account.dm?.groupChannels); - const channelIdLower = channelId.toLowerCase(); - if ( - groupChannels.includes(channelIdLower) || - groupChannels.includes(`slack:${channelIdLower}`) || - groupChannels.includes(`channel:${channelIdLower}`) || - groupChannels.includes(`group:${channelIdLower}`) || - groupChannels.includes(`mpim:${channelIdLower}`) - ) { - SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "group"); - return "group"; - } - - const channelKeys = Object.keys(account.channels ?? {}); - if ( - channelKeys.some((key) => { - const normalized = key.trim().toLowerCase(); - return ( - normalized === channelIdLower || - normalized === `channel:${channelIdLower}` || - normalized.replace(/^#/, "") === channelIdLower - ); - }) - ) { - SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "channel"); - return "channel"; - } - - const token = account.botToken?.trim() || account.userToken || ""; - if (!token) { - SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "unknown"); - return "unknown"; - } - - try { - const client = createSlackWebClient(token); - const info = await client.conversations.info({ channel: channelId }); - const channel = info.channel as { is_im?: boolean; is_mpim?: boolean } | undefined; - const type = channel?.is_im ? "dm" : channel?.is_mpim ? "group" : "channel"; - SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, type); - return type; - } catch { - SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "unknown"); - return "unknown"; - } -} - -async function resolveSlackSession( - params: ResolveOutboundSessionRouteParams, -): Promise { - const parsed = parseSlackTarget(params.target, { defaultKind: "channel" }); - if (!parsed) { - return null; - } - const isDm = parsed.kind === "user"; - let peerKind: ChatType = isDm ? "direct" : "channel"; - if (!isDm && /^G/i.test(parsed.id)) { - // Slack mpim/group DMs share the G-prefix; detect to align session keys with inbound. - const channelType = await resolveSlackChannelType({ - cfg: params.cfg, - accountId: params.accountId, - channelId: parsed.id, - }); - if (channelType === "group") { - peerKind = "group"; - } - if (channelType === "dm") { - peerKind = "direct"; - } - } - const peer: RoutePeer = { - kind: peerKind, - id: parsed.id, - }; - const baseSessionKey = buildBaseSessionKey({ - cfg: params.cfg, - agentId: params.agentId, - channel: "slack", - accountId: params.accountId, - peer, - }); - const threadId = normalizeThreadId(params.threadId ?? params.replyToId); - const threadKeys = resolveThreadSessionKeys({ - baseSessionKey, - threadId, - }); - return { - sessionKey: threadKeys.sessionKey, - baseSessionKey, - peer, - chatType: peerKind === "direct" ? "direct" : "channel", - from: - peerKind === "direct" - ? `slack:${parsed.id}` - : peerKind === "group" - ? `slack:group:${parsed.id}` - : `slack:channel:${parsed.id}`, - to: peerKind === "direct" ? `user:${parsed.id}` : `channel:${parsed.id}`, - threadId, - }; -} - -function resolveDiscordSession( - params: ResolveOutboundSessionRouteParams, -): OutboundSessionRoute | null { - const parsed = parseDiscordTarget(params.target, { - defaultKind: resolveDiscordOutboundTargetKindHint(params), - }); - if (!parsed) { - return null; - } - const isDm = parsed.kind === "user"; - const peer: RoutePeer = { - kind: isDm ? "direct" : "channel", - id: parsed.id, - }; - const baseSessionKey = buildBaseSessionKey({ - cfg: params.cfg, - agentId: params.agentId, - channel: "discord", - accountId: params.accountId, - peer, - }); - const explicitThreadId = normalizeThreadId(params.threadId); - const threadCandidate = explicitThreadId ?? normalizeThreadId(params.replyToId); - // Discord threads use their own channel id; avoid adding a :thread suffix. - const threadKeys = resolveThreadSessionKeys({ - baseSessionKey, - threadId: threadCandidate, - useSuffix: false, - }); - return { - sessionKey: threadKeys.sessionKey, - baseSessionKey, - peer, - chatType: isDm ? "direct" : "channel", - from: isDm ? `discord:${parsed.id}` : `discord:channel:${parsed.id}`, - to: isDm ? `user:${parsed.id}` : `channel:${parsed.id}`, - threadId: explicitThreadId ?? undefined, - }; -} - -function resolveDiscordOutboundTargetKindHint( - params: ResolveOutboundSessionRouteParams, -): DiscordTargetKind | undefined { - const resolvedKind = params.resolvedTarget?.kind; - if (resolvedKind === "user") { - return "user"; - } - if (resolvedKind === "group" || resolvedKind === "channel") { - return "channel"; - } - - const target = params.target.trim(); - if (/^channel:/i.test(target)) { - return "channel"; - } - if (/^(user:|discord:|@|<@!?)/i.test(target)) { - return "user"; - } - return undefined; -} - -function resolveTelegramSession( - params: ResolveOutboundSessionRouteParams, -): OutboundSessionRoute | null { - const parsed = parseTelegramTarget(params.target); - const chatId = parsed.chatId.trim(); - if (!chatId) { - return null; - } - const parsedThreadId = parsed.messageThreadId; - const fallbackThreadId = normalizeThreadId(params.threadId); - const resolvedThreadId = parsedThreadId ?? parseTelegramThreadId(fallbackThreadId); - // Telegram topics are encoded in the peer id (chatId:topic:). - const chatType = resolveTelegramTargetChatType(params.target); - // If the target is a username and we lack a resolvedTarget, default to DM to avoid group keys. - const isGroup = - chatType === "group" || - (chatType === "unknown" && - params.resolvedTarget?.kind && - params.resolvedTarget.kind !== "user"); - // For groups: include thread ID in peerId. For DMs: use simple chatId (thread handled via suffix). - const peerId = - isGroup && resolvedThreadId ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : chatId; - const peer: RoutePeer = { - kind: isGroup ? "group" : "direct", - id: peerId, - }; - const baseSessionKey = buildBaseSessionKey({ - cfg: params.cfg, - agentId: params.agentId, - channel: "telegram", - accountId: params.accountId, - peer, - }); - // Use thread suffix for DM topics to match inbound session key format - const threadKeys = - resolvedThreadId && !isGroup - ? { sessionKey: `${baseSessionKey}:thread:${resolvedThreadId}` } - : null; - return { - sessionKey: threadKeys?.sessionKey ?? baseSessionKey, - baseSessionKey, - peer, - chatType: isGroup ? "group" : "direct", - from: isGroup - ? `telegram:group:${peerId}` - : resolvedThreadId - ? `telegram:${chatId}:topic:${resolvedThreadId}` - : `telegram:${chatId}`, - to: `telegram:${chatId}`, - threadId: resolvedThreadId, - }; -} - function resolveWhatsAppSession( params: ResolveOutboundSessionRouteParams, ): OutboundSessionRoute | null { @@ -385,131 +128,6 @@ function resolveWhatsAppSession( }; } -function resolveSignalSession( - params: ResolveOutboundSessionRouteParams, -): OutboundSessionRoute | null { - const stripped = stripProviderPrefix(params.target, "signal"); - const lowered = stripped.toLowerCase(); - if (lowered.startsWith("group:")) { - const groupId = stripped.slice("group:".length).trim(); - if (!groupId) { - return null; - } - const peer: RoutePeer = { kind: "group", id: groupId }; - const baseSessionKey = buildBaseSessionKey({ - cfg: params.cfg, - agentId: params.agentId, - channel: "signal", - accountId: params.accountId, - peer, - }); - return { - sessionKey: baseSessionKey, - baseSessionKey, - peer, - chatType: "group", - from: `group:${groupId}`, - to: `group:${groupId}`, - }; - } - - let recipient = stripped.trim(); - if (lowered.startsWith("username:")) { - recipient = stripped.slice("username:".length).trim(); - } else if (lowered.startsWith("u:")) { - recipient = stripped.slice("u:".length).trim(); - } - if (!recipient) { - return null; - } - - const uuidCandidate = recipient.toLowerCase().startsWith("uuid:") - ? recipient.slice("uuid:".length) - : recipient; - const sender = resolveSignalSender({ - sourceUuid: looksLikeUuid(uuidCandidate) ? uuidCandidate : null, - sourceNumber: looksLikeUuid(uuidCandidate) ? null : recipient, - }); - const peerId = sender ? resolveSignalPeerId(sender) : recipient; - const displayRecipient = sender ? resolveSignalRecipient(sender) : recipient; - const peer: RoutePeer = { kind: "direct", id: peerId }; - const baseSessionKey = buildBaseSessionKey({ - cfg: params.cfg, - agentId: params.agentId, - channel: "signal", - accountId: params.accountId, - peer, - }); - return { - sessionKey: baseSessionKey, - baseSessionKey, - peer, - chatType: "direct", - from: `signal:${displayRecipient}`, - to: `signal:${displayRecipient}`, - }; -} - -function resolveIMessageSession( - params: ResolveOutboundSessionRouteParams, -): OutboundSessionRoute | null { - const parsed = parseIMessageTarget(params.target); - if (parsed.kind === "handle") { - const handle = normalizeIMessageHandle(parsed.to); - if (!handle) { - return null; - } - const peer: RoutePeer = { kind: "direct", id: handle }; - const baseSessionKey = buildBaseSessionKey({ - cfg: params.cfg, - agentId: params.agentId, - channel: "imessage", - accountId: params.accountId, - peer, - }); - return { - sessionKey: baseSessionKey, - baseSessionKey, - peer, - chatType: "direct", - from: `imessage:${handle}`, - to: `imessage:${handle}`, - }; - } - - const peerId = - parsed.kind === "chat_id" - ? String(parsed.chatId) - : parsed.kind === "chat_guid" - ? parsed.chatGuid - : parsed.chatIdentifier; - if (!peerId) { - return null; - } - const peer: RoutePeer = { kind: "group", id: peerId }; - const baseSessionKey = buildBaseSessionKey({ - cfg: params.cfg, - agentId: params.agentId, - channel: "imessage", - accountId: params.accountId, - peer, - }); - const toPrefix = - parsed.kind === "chat_id" - ? "chat_id" - : parsed.kind === "chat_guid" - ? "chat_guid" - : "chat_identifier"; - return { - sessionKey: baseSessionKey, - baseSessionKey, - peer, - chatType: "group", - from: `imessage:group:${peerId}`, - to: `${toPrefix}:${peerId}`, - }; -} - function resolveMatrixSession( params: ResolveOutboundSessionRouteParams, ): OutboundSessionRoute | null { @@ -944,12 +562,7 @@ type OutboundSessionResolver = ( ) => OutboundSessionRoute | null | Promise; const OUTBOUND_SESSION_RESOLVERS: Partial> = { - slack: resolveSlackSession, - discord: resolveDiscordSession, - telegram: resolveTelegramSession, whatsapp: resolveWhatsAppSession, - signal: resolveSignalSession, - imessage: resolveIMessageSession, matrix: resolveMatrixSession, msteams: resolveMSTeamsSession, mattermost: resolveMattermostSession, diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index 5c2303d7154..150dd4e26ea 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { discordPlugin } from "../../../extensions/discord/src/channel.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; +import { setDefaultChannelPluginRegistryForTests } from "../../commands/channel-test-helpers.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; @@ -888,6 +889,10 @@ const discordConfig = { } as OpenClawConfig; describe("outbound policy", () => { + beforeEach(() => { + setDefaultChannelPluginRegistryForTests(); + }); + it("allows cross-provider sends when enabled", () => { const cfg = { ...slackConfig, @@ -930,6 +935,10 @@ describe("outbound policy", () => { }); describe("resolveOutboundSessionRoute", () => { + beforeEach(() => { + setDefaultChannelPluginRegistryForTests(); + }); + const baseConfig = {} as OpenClawConfig; it("resolves provider-specific session routes", async () => { diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index c02cf8fe20a..ce05a95b47a 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -8,7 +8,6 @@ export { readReactionParams, readStringParam, } from "../agents/tools/common.js"; -export type { ChannelDock } from "../channels/dock.js"; export { resolveMentionGatingWithBypass } from "../channels/mention-gating.js"; export { deleteAccountFromConfigSection, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 5f1ccd91bbe..1b37138e101 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -248,7 +248,6 @@ export { buildNestedDmConfigSchema, buildCatchallMultiAccountChannelSchema, } from "../channels/plugins/config-schema.js"; -export type { ChannelDock } from "../channels/dock.js"; export { getChatChannelMeta } from "../channels/registry.js"; export { compileAllowlist, @@ -330,6 +329,7 @@ export { normalizeAgentId, resolveThreadSessionKeys, } from "../routing/session-key.js"; +export { buildAgentSessionKey, type RoutePeer } from "../routing/resolve-route.js"; export { formatAllowFromLowercase, formatNormalizedAllowFromEntries, diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index cb8b059dc2c..37e3b9fde26 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -3,7 +3,6 @@ export { jsonResult, readStringParam } from "../agents/tools/common.js"; export type { ReplyPayload } from "../auto-reply/types.js"; -export type { ChannelDock } from "../channels/dock.js"; export { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index 8f4866e949e..b7b95910132 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -3,7 +3,6 @@ export type { ReplyPayload } from "../auto-reply/types.js"; export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js"; -export type { ChannelDock } from "../channels/dock.js"; export { resolveMentionGatingWithBypass } from "../channels/mention-gating.js"; export { deleteAccountFromConfigSection, diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 103755b4ac1..da9bcd3e993 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createJiti } from "jiti"; -import type { ChannelDock } from "../channels/dock.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { isChannelConfigured } from "../config/plugin-auto-enable.js"; @@ -407,7 +406,6 @@ function resolvePluginModuleExport(moduleExport: unknown): { function resolveSetupChannelRegistration(moduleExport: unknown): { plugin?: ChannelPlugin; - dock?: ChannelDock; } { const resolved = moduleExport && @@ -420,14 +418,12 @@ function resolveSetupChannelRegistration(moduleExport: unknown): { } const setup = resolved as { plugin?: unknown; - dock?: unknown; }; if (!setup.plugin || typeof setup.plugin !== "object") { return {}; } return { plugin: setup.plugin as ChannelPlugin, - ...(setup.dock && typeof setup.dock === "object" ? { dock: setup.dock as ChannelDock } : {}), }; } @@ -1167,10 +1163,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi hookPolicy: entry?.hooks, registrationMode, }); - api.registerChannel({ - plugin: setupRegistration.plugin, - ...(setupRegistration.dock ? { dock: setupRegistration.dock } : {}), - }); + api.registerChannel(setupRegistration.plugin); registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); continue; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 9b450af26e7..fabf9fa1069 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -1,6 +1,5 @@ import path from "node:path"; import type { AnyAgentTool } from "../agents/tools/common.js"; -import type { ChannelDock } from "../channels/dock.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import { registerContextEngineForOwner } from "../context-engine/registry.js"; import type { @@ -82,7 +81,6 @@ export type PluginChannelRegistration = { pluginId: string; pluginName?: string; plugin: ChannelPlugin; - dock?: ChannelDock; source: string; rootDir?: string; }; @@ -516,7 +514,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { pluginId: record.id, pluginName: record.name, plugin, - dock: normalized.dock, source: record.source, rootDir: record.rootDir, }); diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 6dc5788b9eb..04da1293a09 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -16,7 +16,6 @@ import type { ProviderCapabilities } from "../agents/provider-capabilities.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ThinkLevel } from "../auto-reply/thinking.js"; import type { ReplyPayload } from "../auto-reply/types.js"; -import type { ChannelDock } from "../channels/dock.js"; import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.js"; import type { createVpsAwareOAuthHandlers } from "../commands/oauth-flow.js"; import type { OnboardOptions } from "../commands/onboard-types.js"; @@ -1155,7 +1154,6 @@ export type OpenClawPluginService = { export type OpenClawPluginChannelRegistration = { plugin: ChannelPlugin; - dock?: ChannelDock; }; export type OpenClawPluginDefinition = {