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 { resolveDiscordGroupRequireMention, resolveIMessageGroupRequireMention, resolveSlackGroupRequireMention, resolveTelegramGroupRequireMention, resolveWhatsAppGroupRequireMention, } from "./plugins/group-mentions.js"; import type { ChannelCapabilities, ChannelCommandAdapter, ChannelElevatedAdapter, ChannelGroupAdapter, ChannelId, ChannelMentionAdapter, ChannelThreadingAdapter, } from "./plugins/types.js"; import { CHAT_CHANNEL_ORDER } 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 }) => ({ currentChannelId: context.To?.trim() || undefined, currentThreadTs: context.ReplyToId, 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, }), }, }, msteams: { id: "msteams", capabilities: { chatTypes: ["direct", "channel", "thread"], polls: true, threads: true, media: true, }, outbound: { textChunkLimit: 4000 }, config: { resolveAllowFrom: ({ cfg }) => cfg.channels?.msteams?.allowFrom ?? [], formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom), }, threading: { buildToolContext: ({ context, hasRepliedRef }) => ({ currentChannelId: context.To?.trim() || undefined, currentThreadTs: context.ReplyToId, hasRepliedRef, }), }, }, }; export function listChannelDocks(): ChannelDock[] { return CHAT_CHANNEL_ORDER.map((id) => DOCKS[id]); } export function getChannelDock(id: ChannelId): ChannelDock | undefined { return DOCKS[id]; }