import { resolveChannelGroupRequireMention, resolveChannelGroupToolsPolicy, } from "../config/group-policy.js"; import { inspectDiscordAccount } from "../discord/account-inspect.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 { resolveSignalAccount } from "../signal/accounts.js"; import { inspectSlackAccount } from "../slack/account-inspect.js"; import { resolveSlackReplyToMode } from "../slack/accounts.js"; import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js"; import { inspectTelegramAccount } from "../telegram/account-inspect.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, resolveWhatsAppMentionStripPatterns, } 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: { stripPatterns: ({ ctx }) => resolveWhatsAppMentionStripPatterns(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: { stripPatterns: () => ["<@!?\\d+>"], }, 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: { stripPatterns: () => ["<@[^>]+>"], }, 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); }