import type { OpenClawConfig } from "../config/config.js"; import type { ChannelCapabilities, ChannelCommandAdapter, ChannelElevatedAdapter, ChannelGroupAdapter, ChannelId, ChannelAgentPromptAdapter, ChannelMentionAdapter, ChannelPlugin, ChannelThreadingAdapter, } from "./plugins/types.js"; import { resolveDiscordAccount } from "../discord/accounts.js"; import { resolveIMessageAccount } from "../imessage/accounts.js"; import { requireActivePluginRegistry } from "../plugins/runtime.js"; import { normalizeAccountId } from "../routing/session-key.js"; import { resolveSignalAccount } from "../signal/accounts.js"; import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js"; import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js"; import { resolveTelegramAccount } from "../telegram/accounts.js"; import { normalizeE164 } from "../utils.js"; import { resolveWhatsAppAccount } from "../web/accounts.js"; import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; import { resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, resolveGoogleChatGroupRequireMention, resolveGoogleChatGroupToolPolicy, resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupToolPolicy, } from "./plugins/group-mentions.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?: { resolveAllowFrom?: (params: { cfg: OpenClawConfig; accountId?: string | null; }) => Array | undefined; formatAllowFrom?: (params: { cfg: OpenClawConfig; accountId?: string | null; allowFrom: Array; }) => string[]; }; groups?: ChannelGroupAdapter; mentions?: ChannelMentionAdapter; threading?: ChannelThreadingAdapter; agentPrompt?: ChannelAgentPromptAdapter; }; type ChannelDockStreaming = { blockStreamingCoalesceDefaults?: { minChars?: number; idleMs?: number; }; }; const formatLower = (allowFrom: Array) => allowFrom .map((entry) => String(entry).trim()) .filter(Boolean) .map((entry) => entry.toLowerCase()); const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // 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: { textChunkLimit: 4000 }, config: { resolveAllowFrom: ({ cfg, accountId }) => (resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => String(entry), ), formatAllowFrom: ({ allowFrom }) => allowFrom .map((entry) => String(entry).trim()) .filter(Boolean) .map((entry) => entry.replace(/^(telegram|tg):/i, "")) .map((entry) => entry.toLowerCase()), }, groups: { resolveRequireMention: resolveTelegramGroupRequireMention, resolveToolPolicy: resolveTelegramGroupToolPolicy, }, threading: { resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first", buildToolContext: ({ context, hasRepliedRef }) => { const threadId = context.MessageThreadId ?? context.ReplyToId; return { currentChannelId: context.To?.trim() || undefined, currentThreadTs: threadId != null ? String(threadId) : undefined, hasRepliedRef, }; }, }, }, whatsapp: { id: "whatsapp", capabilities: { chatTypes: ["direct", "group"], polls: true, reactions: true, media: true, }, commands: { enforceOwnerForCommands: true, skipWhenConfigEmpty: true, }, outbound: { textChunkLimit: 4000 }, config: { resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [], formatAllowFrom: ({ allowFrom }) => allowFrom .map((entry) => String(entry).trim()) .filter((entry): entry is string => Boolean(entry)) .map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry))) .filter((entry): entry is string => Boolean(entry)), }, groups: { resolveRequireMention: resolveWhatsAppGroupRequireMention, resolveToolPolicy: resolveWhatsAppGroupToolPolicy, resolveGroupIntroHint: () => "WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).", }, mentions: { stripPatterns: ({ ctx }) => { const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, ""); if (!selfE164) { return []; } const escaped = escapeRegExp(selfE164); return [escaped, `@${escaped}`]; }, }, 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: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, elevated: { allowFromFallback: ({ cfg }) => cfg.channels?.discord?.dm?.allowFrom, }, config: { resolveAllowFrom: ({ cfg, accountId }) => (resolveDiscordAccount({ cfg, accountId }).config.dm?.allowFrom ?? []).map((entry) => String(entry), ), formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom), }, groups: { resolveRequireMention: resolveDiscordGroupRequireMention, resolveToolPolicy: resolveDiscordGroupToolPolicy, }, mentions: { stripPatterns: () => ["<@!?\\d+>"], }, threading: { resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off", buildToolContext: ({ context, hasRepliedRef }) => ({ currentChannelId: context.To?.trim() || undefined, currentThreadTs: context.ReplyToId, hasRepliedRef, }), }, }, googlechat: { id: "googlechat", capabilities: { chatTypes: ["direct", "group", "thread"], reactions: true, media: true, threads: true, blockStreaming: true, }, outbound: { textChunkLimit: 4000 }, config: { resolveAllowFrom: ({ cfg, accountId }) => { const channel = cfg.channels?.googlechat as | { accounts?: Record } }>; dm?: { allowFrom?: Array }; } | undefined; const normalized = normalizeAccountId(accountId); const account = channel?.accounts?.[normalized] ?? channel?.accounts?.[ Object.keys(channel?.accounts ?? {}).find( (key) => key.toLowerCase() === normalized.toLowerCase(), ) ?? "" ]; return (account?.dm?.allowFrom ?? channel?.dm?.allowFrom ?? []).map((entry) => String(entry), ); }, formatAllowFrom: ({ allowFrom }) => allowFrom .map((entry) => String(entry).trim()) .filter(Boolean) .map((entry) => entry .replace(/^(googlechat|google-chat|gchat):/i, "") .replace(/^user:/i, "") .replace(/^users\//i, "") .toLowerCase(), ), }, groups: { resolveRequireMention: resolveGoogleChatGroupRequireMention, resolveToolPolicy: resolveGoogleChatGroupToolPolicy, }, 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, }; }, }, }, slack: { id: "slack", capabilities: { chatTypes: ["direct", "channel", "thread"], reactions: true, media: true, nativeCommands: true, threads: true, }, outbound: { textChunkLimit: 4000 }, streaming: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, config: { resolveAllowFrom: ({ cfg, accountId }) => (resolveSlackAccount({ cfg, accountId }).dm?.allowFrom ?? []).map((entry) => String(entry)), formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom), }, groups: { resolveRequireMention: resolveSlackGroupRequireMention, resolveToolPolicy: resolveSlackGroupToolPolicy, }, threading: { resolveReplyToMode: ({ cfg, accountId, chatType }) => resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), allowTagsWhenOff: true, buildToolContext: (params) => buildSlackThreadingToolContext(params), }, }, signal: { id: "signal", capabilities: { chatTypes: ["direct", "group"], reactions: true, media: true, }, outbound: { textChunkLimit: 4000 }, streaming: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, config: { resolveAllowFrom: ({ cfg, accountId }) => (resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => String(entry), ), formatAllowFrom: ({ allowFrom }) => allowFrom .map((entry) => String(entry).trim()) .filter(Boolean) .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) .filter(Boolean), }, threading: { buildToolContext: ({ context, hasRepliedRef }) => { const isDirect = context.ChatType?.toLowerCase() === "direct"; const channelId = (isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined; return { currentChannelId: channelId, currentThreadTs: context.ReplyToId, hasRepliedRef, }; }, }, }, imessage: { id: "imessage", capabilities: { chatTypes: ["direct", "group"], reactions: true, media: true, }, outbound: { textChunkLimit: 4000 }, config: { resolveAllowFrom: ({ cfg, accountId }) => (resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => String(entry), ), formatAllowFrom: ({ allowFrom }) => allowFrom.map((entry) => String(entry).trim()).filter(Boolean), }, groups: { resolveRequireMention: resolveIMessageGroupRequireMention, resolveToolPolicy: resolveIMessageGroupToolPolicy, }, threading: { buildToolContext: ({ context, hasRepliedRef }) => { const isDirect = context.ChatType?.toLowerCase() === "direct"; const channelId = (isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined; return { currentChannelId: channelId, currentThreadTs: context.ReplyToId, hasRepliedRef, }; }, }, }, }; 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, } : 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); }