import type { ClawdbotConfig } from "../config/config.js"; import { resolveDiscordAccount } from "../discord/accounts.js"; import { resolveIMessageAccount } from "../imessage/accounts.js"; import { resolveSignalAccount } from "../signal/accounts.js"; import { resolveSlackAccount } from "../slack/accounts.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 { getActivePluginRegistry } from "../plugins/runtime.js"; import { resolveDiscordGroupRequireMention, resolveIMessageGroupRequireMention, resolveSlackGroupRequireMention, resolveTelegramGroupRequireMention, resolveWhatsAppGroupRequireMention, } from "./plugins/group-mentions.js"; import type { ChannelCapabilities, ChannelCommandAdapter, ChannelElevatedAdapter, ChannelGroupAdapter, ChannelId, ChannelMentionAdapter, ChannelPlugin, ChannelThreadingAdapter, } from "./plugins/types.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: ClawdbotConfig; accountId?: string | null; }) => Array | undefined; formatAllowFrom?: (params: { cfg: ClawdbotConfig; accountId?: string | null; allowFrom: Array; }) => string[]; }; groups?: ChannelGroupAdapter; mentions?: ChannelMentionAdapter; threading?: ChannelThreadingAdapter; }; 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, }, 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, 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 }) => ({ currentChannelId: context.To?.trim() || undefined, 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, }, mentions: { stripPatterns: () => ["<@!?\\d+>"], }, threading: { resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off", buildToolContext: ({ context, hasRepliedRef }) => ({ currentChannelId: context.To?.trim() || undefined, currentThreadTs: context.ReplyToId, 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, }, threading: { resolveReplyToMode: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off", allowTagsWhenOff: true, buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => { const configuredReplyToMode = resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off"; const effectiveReplyToMode = context.ThreadLabel ? "all" : configuredReplyToMode; return { currentChannelId: context.To?.startsWith("channel:") ? context.To.slice("channel:".length) : undefined, currentThreadTs: context.ReplyToId, replyToMode: effectiveReplyToMode, hasRepliedRef, }; }, }, }, 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 }) => ({ currentChannelId: context.To?.trim() || undefined, 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, }, threading: { buildToolContext: ({ context, hasRepliedRef }) => ({ currentChannelId: context.To?.trim() || undefined, 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, }; } function listPluginDockEntries(): Array<{ id: ChannelId; dock: ChannelDock; order?: number }> { const registry = getActivePluginRegistry(); if (!registry) return []; 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 = getActivePluginRegistry(); const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id); if (!pluginEntry) return undefined; return pluginEntry.dock ?? buildDockFromPlugin(pluginEntry.plugin); }