From d05c3d0659f46c708d744aaa58ae39233c619794 Mon Sep 17 00:00:00 2001 From: Bohdan Podvirnyi Date: Tue, 13 Jan 2026 21:13:05 +0200 Subject: [PATCH] feat: make telegram reactions visible to clawdbot --- src/config/types.ts | 1838 +++++++++++++++++ src/config/zod-schema.ts | 1445 ++++++++++++++ src/telegram/bot.test.ts | 2411 +++++++++++++++++++++++ src/telegram/bot.ts | 104 +- src/telegram/send.ts | 12 +- src/telegram/sent-message-cache.test.ts | 38 + src/telegram/sent-message-cache.ts | 70 + 7 files changed, 5915 insertions(+), 3 deletions(-) create mode 100644 src/telegram/bot.test.ts create mode 100644 src/telegram/sent-message-cache.test.ts create mode 100644 src/telegram/sent-message-cache.ts diff --git a/src/config/types.ts b/src/config/types.ts index 368618262..bbc165b8a 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -24,3 +24,1841 @@ export * from "./types.slack.js"; export * from "./types.telegram.js"; export * from "./types.tools.js"; export * from "./types.whatsapp.js"; + +export type OutboundRetryConfig = { + /** Max retry attempts for outbound requests (default: 3). */ + attempts?: number; + /** Minimum retry delay in ms (default: 300-500ms depending on provider). */ + minDelayMs?: number; + /** Maximum retry delay cap in ms (default: 30000). */ + maxDelayMs?: number; + /** Jitter factor (0-1) applied to delays (default: 0.1). */ + jitter?: number; +}; + +export type BlockStreamingCoalesceConfig = { + minChars?: number; + maxChars?: number; + idleMs?: number; +}; + +export type BlockStreamingChunkConfig = { + minChars?: number; + maxChars?: number; + breakPreference?: "paragraph" | "newline" | "sentence"; +}; + +export type HumanDelayConfig = { + /** Delay style for block replies (off|natural|custom). */ + mode?: "off" | "natural" | "custom"; + /** Minimum delay in milliseconds (default: 800). */ + minMs?: number; + /** Maximum delay in milliseconds (default: 2500). */ + maxMs?: number; +}; + +export type SessionSendPolicyAction = "allow" | "deny"; +export type SessionSendPolicyMatch = { + channel?: string; + chatType?: "direct" | "group" | "room"; + keyPrefix?: string; +}; +export type SessionSendPolicyRule = { + action: SessionSendPolicyAction; + match?: SessionSendPolicyMatch; +}; +export type SessionSendPolicyConfig = { + default?: SessionSendPolicyAction; + rules?: SessionSendPolicyRule[]; +}; + +export type SessionConfig = { + scope?: SessionScope; + resetTriggers?: string[]; + idleMinutes?: number; + heartbeatIdleMinutes?: number; + store?: string; + typingIntervalSeconds?: number; + typingMode?: TypingMode; + mainKey?: string; + sendPolicy?: SessionSendPolicyConfig; + agentToAgent?: { + /** Max ping-pong turns between requester/target (0–5). Default: 5. */ + maxPingPongTurns?: number; + }; +}; + +export type LoggingConfig = { + level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace"; + file?: string; + consoleLevel?: + | "silent" + | "fatal" + | "error" + | "warn" + | "info" + | "debug" + | "trace"; + consoleStyle?: "pretty" | "compact" | "json"; + /** Redact sensitive tokens in tool summaries. Default: "tools". */ + redactSensitive?: "off" | "tools"; + /** Regex patterns used to redact sensitive tokens (defaults apply when unset). */ + redactPatterns?: string[]; +}; + +export type WebReconnectConfig = { + initialMs?: number; + maxMs?: number; + factor?: number; + jitter?: number; + maxAttempts?: number; // 0 = unlimited +}; + +export type WebConfig = { + /** If false, do not start the WhatsApp web provider. Default: true. */ + enabled?: boolean; + heartbeatSeconds?: number; + reconnect?: WebReconnectConfig; +}; + +// Provider docking: allowlists keyed by provider id (and internal "webchat"). +export type AgentElevatedAllowFromConfig = Partial< + Record> +>; + +export type IdentityConfig = { + name?: string; + theme?: string; + emoji?: string; +}; + +export type WhatsAppActionConfig = { + reactions?: boolean; + sendMessage?: boolean; + polls?: boolean; +}; + +export type WhatsAppConfig = { + /** Optional per-account WhatsApp configuration (multi-account). */ + accounts?: Record; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** + * Inbound message prefix (WhatsApp only). + * Default: `[{agents.list[].identity.name}]` (or `[clawdbot]`) when allowFrom is empty, else `""`. + */ + messagePrefix?: string; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; + /** + * Same-phone setup (bot uses your personal WhatsApp number). + */ + selfChatMode?: boolean; + /** Optional allowlist for WhatsApp direct chats (E.164). */ + allowFrom?: string[]; + /** Optional allowlist for WhatsApp group senders (E.164). */ + groupAllowFrom?: string[]; + /** + * Controls how group messages are handled: + * - "open": groups bypass allowFrom, only mention-gating applies + * - "disabled": block all group messages entirely + * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom + */ + groupPolicy?: GroupPolicy; + /** Max group messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Maximum media file size in MB. Default: 50. */ + mediaMaxMb?: number; + /** Disable block streaming for this account. */ + blockStreaming?: boolean; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + /** Per-action tool gating (default: true for all). */ + actions?: WhatsAppActionConfig; + groups?: Record< + string, + { + requireMention?: boolean; + } + >; + /** Acknowledgment reaction sent immediately upon message receipt. */ + ackReaction?: { + /** Emoji to use for acknowledgment (e.g., "👀"). Empty = disabled. */ + emoji?: string; + /** Send reactions in direct chats. Default: true. */ + direct?: boolean; + /** + * Send reactions in group chats: + * - "always": react to all group messages + * - "mentions": react only when bot is mentioned + * - "never": never react in groups + * Default: "mentions" + */ + group?: "always" | "mentions" | "never"; + }; +}; + +export type WhatsAppAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** If false, do not start this WhatsApp account provider. Default: true. */ + enabled?: boolean; + /** Inbound message prefix override for this account (WhatsApp only). */ + messagePrefix?: string; + /** Override auth directory (Baileys multi-file auth state). */ + authDir?: string; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; + /** Same-phone setup for this account (bot uses your personal WhatsApp number). */ + selfChatMode?: boolean; + allowFrom?: string[]; + groupAllowFrom?: string[]; + groupPolicy?: GroupPolicy; + /** Max group messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; + textChunkLimit?: number; + mediaMaxMb?: number; + blockStreaming?: boolean; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + groups?: Record< + string, + { + requireMention?: boolean; + } + >; + /** Acknowledgment reaction sent immediately upon message receipt. */ + ackReaction?: { + /** Emoji to use for acknowledgment (e.g., "👀"). Empty = disabled. */ + emoji?: string; + /** Send reactions in direct chats. Default: true. */ + direct?: boolean; + /** + * Send reactions in group chats: + * - "always": react to all group messages + * - "mentions": react only when bot is mentioned + * - "never": never react in groups + * Default: "mentions" + */ + group?: "always" | "mentions" | "never"; + }; +}; + +export type BrowserProfileConfig = { + /** CDP port for this profile. Allocated once at creation, persisted permanently. */ + cdpPort?: number; + /** CDP URL for this profile (use for remote Chrome). */ + cdpUrl?: string; + /** Profile color (hex). Auto-assigned at creation. */ + color: string; +}; +export type BrowserConfig = { + enabled?: boolean; + /** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */ + controlUrl?: string; + /** Base URL of the CDP endpoint. Default: controlUrl with port + 1. */ + cdpUrl?: string; + /** Accent color for the clawd browser profile (hex). Default: #FF4500 */ + color?: string; + /** Override the browser executable path (macOS/Linux). */ + executablePath?: string; + /** Start Chrome headless (best-effort). Default: false */ + headless?: boolean; + /** Pass --no-sandbox to Chrome (Linux containers). Default: false */ + noSandbox?: boolean; + /** If true: never launch; only attach to an existing browser. Default: false */ + attachOnly?: boolean; + /** Default profile to use when profile param is omitted. Default: "clawd" */ + defaultProfile?: string; + /** Named browser profiles with explicit CDP ports or URLs. */ + profiles?: Record; +}; + +export type CronConfig = { + enabled?: boolean; + store?: string; + maxConcurrentRuns?: number; +}; + +export type HookMappingMatch = { + path?: string; + source?: string; +}; + +export type HookMappingTransform = { + module: string; + export?: string; +}; + +export type HookMappingConfig = { + id?: string; + match?: HookMappingMatch; + action?: "wake" | "agent"; + wakeMode?: "now" | "next-heartbeat"; + name?: string; + sessionKey?: string; + messageTemplate?: string; + textTemplate?: string; + deliver?: boolean; + channel?: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "signal" + | "imessage" + | "msteams"; + to?: string; + /** Override model for this hook (provider/model or alias). */ + model?: string; + thinking?: string; + timeoutSeconds?: number; + transform?: HookMappingTransform; +}; + +export type HooksGmailTailscaleMode = "off" | "serve" | "funnel"; + +export type HooksGmailConfig = { + account?: string; + label?: string; + topic?: string; + subscription?: string; + pushToken?: string; + hookUrl?: string; + includeBody?: boolean; + maxBytes?: number; + renewEveryMinutes?: number; + serve?: { + bind?: string; + port?: number; + path?: string; + }; + tailscale?: { + mode?: HooksGmailTailscaleMode; + path?: string; + /** Optional tailscale serve/funnel target (port, host:port, or full URL). */ + target?: string; + }; + /** Optional model override for Gmail hook processing (provider/model or alias). */ + model?: string; + /** Optional thinking level override for Gmail hook processing. */ + thinking?: "off" | "minimal" | "low" | "medium" | "high"; +}; + +export type HooksConfig = { + enabled?: boolean; + path?: string; + token?: string; + maxBodyBytes?: number; + presets?: string[]; + transformsDir?: string; + mappings?: HookMappingConfig[]; + gmail?: HooksGmailConfig; +}; + +export type TelegramActionConfig = { + reactions?: boolean; + sendMessage?: boolean; +}; + +export type TelegramAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** Override native command registration for Telegram (bool or "auto"). */ + commands?: ProviderCommandsConfig; + /** + * Controls how Telegram direct chats (DMs) are handled: + * - "pairing" (default): unknown senders get a pairing code; owner must approve + * - "allowlist": only allow senders in allowFrom (or paired allow store) + * - "open": allow all inbound DMs (requires allowFrom to include "*") + * - "disabled": ignore all inbound DMs + */ + dmPolicy?: DmPolicy; + /** If false, do not start this Telegram account. Default: true. */ + enabled?: boolean; + botToken?: string; + /** Path to file containing bot token (for secret managers like agenix). */ + tokenFile?: string; + /** Control reply threading when reply tags are present (off|first|all). */ + replyToMode?: ReplyToMode; + groups?: Record; + allowFrom?: Array; + /** Optional allowlist for Telegram group senders (user ids or usernames). */ + groupAllowFrom?: Array; + /** + * Controls how group messages are handled: + * - "open": groups bypass allowFrom, only mention-gating applies + * - "disabled": block all group messages entirely + * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom + */ + groupPolicy?: GroupPolicy; + /** Max group messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Disable block streaming for this account. */ + blockStreaming?: boolean; + /** Chunking config for draft streaming in `streamMode: "block"`. */ + draftChunk?: BlockStreamingChunkConfig; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + /** Draft streaming mode for Telegram (off|partial|block). Default: partial. */ + streamMode?: "off" | "partial" | "block"; + /** Reaction notification mode: off, own (default), all. */ + reactionNotifications?: "off" | "own" | "all"; + mediaMaxMb?: number; + /** Retry policy for outbound Telegram API calls. */ + retry?: OutboundRetryConfig; + proxy?: string; + webhookUrl?: string; + webhookSecret?: string; + webhookPath?: string; + /** Per-action tool gating (default: true for all). */ + actions?: TelegramActionConfig; +}; + +export type TelegramTopicConfig = { + requireMention?: boolean; + /** If specified, only load these skills for this topic. Omit = all skills; empty = no skills. */ + skills?: string[]; + /** If false, disable the bot for this topic. */ + enabled?: boolean; + /** Optional allowlist for topic senders (ids or usernames). */ + allowFrom?: Array; + /** Optional system prompt snippet for this topic. */ + systemPrompt?: string; +}; + +export type TelegramGroupConfig = { + requireMention?: boolean; + /** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */ + skills?: string[]; + /** Per-topic configuration (key is message_thread_id as string) */ + topics?: Record; + /** If false, disable the bot for this group (and its topics). */ + enabled?: boolean; + /** Optional allowlist for group senders (ids or usernames). */ + allowFrom?: Array; + /** Optional system prompt snippet for this group. */ + systemPrompt?: string; +}; + +export type TelegramConfig = { + /** Optional per-account Telegram configuration (multi-account). */ + accounts?: Record; +} & TelegramAccountConfig; + +export type DiscordDmConfig = { + /** If false, ignore all incoming Discord DMs. Default: true. */ + enabled?: boolean; + /** Direct message access policy (default: pairing). */ + policy?: DmPolicy; + /** Allowlist for DM senders (ids or names). */ + allowFrom?: Array; + /** If true, allow group DMs (default: false). */ + groupEnabled?: boolean; + /** Optional allowlist for group DM channels (ids or slugs). */ + groupChannels?: Array; +}; + +export type DiscordGuildChannelConfig = { + allow?: boolean; + requireMention?: boolean; + /** If specified, only load these skills for this channel. Omit = all skills; empty = no skills. */ + skills?: string[]; + /** If false, disable the bot for this channel. */ + enabled?: boolean; + /** Optional allowlist for channel senders (ids or names). */ + users?: Array; + /** Optional system prompt snippet for this channel. */ + systemPrompt?: string; +}; + +export type DiscordReactionNotificationMode = + | "off" + | "own" + | "all" + | "allowlist"; + +export type DiscordGuildEntry = { + slug?: string; + requireMention?: boolean; + /** Reaction notification mode (off|own|all|allowlist). Default: own. */ + reactionNotifications?: DiscordReactionNotificationMode; + users?: Array; + channels?: Record; +}; + +export type DiscordActionConfig = { + reactions?: boolean; + stickers?: boolean; + polls?: boolean; + permissions?: boolean; + messages?: boolean; + threads?: boolean; + pins?: boolean; + search?: boolean; + memberInfo?: boolean; + roleInfo?: boolean; + roles?: boolean; + channelInfo?: boolean; + voiceStatus?: boolean; + events?: boolean; + moderation?: boolean; + emojiUploads?: boolean; + stickerUploads?: boolean; + channels?: boolean; +}; + +export type DiscordAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** Override native command registration for Discord (bool or "auto"). */ + commands?: ProviderCommandsConfig; + /** If false, do not start this Discord account. Default: true. */ + enabled?: boolean; + token?: string; + /** Allow bot-authored messages to trigger replies (default: false). */ + allowBots?: boolean; + /** + * Controls how guild channel messages are handled: + * - "open": guild channels bypass allowlists; mention-gating applies + * - "disabled": block all guild channel messages + * - "allowlist": only allow channels present in discord.guilds.*.channels + */ + groupPolicy?: GroupPolicy; + /** Outbound text chunk size (chars). Default: 2000. */ + textChunkLimit?: number; + /** Disable block streaming for this account. */ + blockStreaming?: boolean; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + /** + * Soft max line count per Discord message. + * Discord clients can clip/collapse very tall messages; splitting by lines + * keeps replies readable in-channel. Default: 17. + */ + maxLinesPerMessage?: number; + mediaMaxMb?: number; + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; + /** Retry policy for outbound Discord API calls. */ + retry?: OutboundRetryConfig; + /** Per-action tool gating (default: true for all). */ + actions?: DiscordActionConfig; + /** Control reply threading when reply tags are present (off|first|all). */ + replyToMode?: ReplyToMode; + dm?: DiscordDmConfig; + /** New per-guild config keyed by guild id or slug. */ + guilds?: Record; +}; + +export type DiscordConfig = { + /** Optional per-account Discord configuration (multi-account). */ + accounts?: Record; +} & DiscordAccountConfig; + +export type SlackDmConfig = { + /** If false, ignore all incoming Slack DMs. Default: true. */ + enabled?: boolean; + /** Direct message access policy (default: pairing). */ + policy?: DmPolicy; + /** Allowlist for DM senders (ids). */ + allowFrom?: Array; + /** If true, allow group DMs (default: false). */ + groupEnabled?: boolean; + /** Optional allowlist for group DM channels (ids or slugs). */ + groupChannels?: Array; +}; + +export type SlackChannelConfig = { + /** If false, disable the bot in this channel. (Alias for allow: false.) */ + enabled?: boolean; + /** Legacy channel allow toggle; prefer enabled. */ + allow?: boolean; + /** Require mentioning the bot to trigger replies. */ + requireMention?: boolean; + /** Allow bot-authored messages to trigger replies (default: false). */ + allowBots?: boolean; + /** Allowlist of users that can invoke the bot in this channel. */ + users?: Array; + /** Optional skill filter for this channel. */ + skills?: string[]; + /** Optional system prompt for this channel. */ + systemPrompt?: string; +}; + +export type SignalReactionNotificationMode = + | "off" + | "own" + | "all" + | "allowlist"; + +export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist"; + +export type SlackActionConfig = { + reactions?: boolean; + messages?: boolean; + pins?: boolean; + search?: boolean; + permissions?: boolean; + memberInfo?: boolean; + channelInfo?: boolean; + emojiList?: boolean; +}; + +export type SlackSlashCommandConfig = { + /** Enable handling for the configured slash command (default: false). */ + enabled?: boolean; + /** Slash command name (default: "clawd"). */ + name?: string; + /** Session key prefix for slash commands (default: "slack:slash"). */ + sessionPrefix?: string; + /** Reply ephemerally (default: true). */ + ephemeral?: boolean; +}; + +export type SlackAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** Override native command registration for Slack (bool or "auto"). */ + commands?: ProviderCommandsConfig; + /** If false, do not start this Slack account. Default: true. */ + enabled?: boolean; + botToken?: string; + appToken?: string; + /** Allow bot-authored messages to trigger replies (default: false). */ + allowBots?: boolean; + /** + * Controls how channel messages are handled: + * - "open": channels bypass allowlists; mention-gating applies + * - "disabled": block all channel messages + * - "allowlist": only allow channels present in channels.slack.channels + */ + groupPolicy?: GroupPolicy; + /** Max channel messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; + textChunkLimit?: number; + blockStreaming?: boolean; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + mediaMaxMb?: number; + /** Reaction notification mode (off|own|all|allowlist). Default: own. */ + reactionNotifications?: SlackReactionNotificationMode; + /** Allowlist for reaction notifications when mode is allowlist. */ + reactionAllowlist?: Array; + /** Control reply threading when reply tags are present (off|first|all). */ + replyToMode?: ReplyToMode; + actions?: SlackActionConfig; + slashCommand?: SlackSlashCommandConfig; + dm?: SlackDmConfig; + channels?: Record; +}; + +export type SlackConfig = { + /** Optional per-account Slack configuration (multi-account). */ + accounts?: Record; +} & SlackAccountConfig; + +export type SignalAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** If false, do not start this Signal account. Default: true. */ + enabled?: boolean; + /** Optional explicit E.164 account for signal-cli. */ + account?: string; + /** Optional full base URL for signal-cli HTTP daemon. */ + httpUrl?: string; + /** HTTP host for signal-cli daemon (default 127.0.0.1). */ + httpHost?: string; + /** HTTP port for signal-cli daemon (default 8080). */ + httpPort?: number; + /** signal-cli binary path (default: signal-cli). */ + cliPath?: string; + /** Auto-start signal-cli daemon (default: true if httpUrl not set). */ + autoStart?: boolean; + receiveMode?: "on-start" | "manual"; + ignoreAttachments?: boolean; + ignoreStories?: boolean; + sendReadReceipts?: boolean; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; + allowFrom?: Array; + /** Optional allowlist for Signal group senders (E.164). */ + groupAllowFrom?: Array; + /** + * Controls how group messages are handled: + * - "open": groups bypass allowFrom, no extra gating + * - "disabled": block all group messages + * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom + */ + groupPolicy?: GroupPolicy; + /** Max group messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + blockStreaming?: boolean; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + mediaMaxMb?: number; + /** Reaction notification mode (off|own|all|allowlist). Default: own. */ + reactionNotifications?: SignalReactionNotificationMode; + /** Allowlist for reaction notifications when mode is allowlist. */ + reactionAllowlist?: Array; +}; + +export type SignalConfig = { + /** Optional per-account Signal configuration (multi-account). */ + accounts?: Record; +} & SignalAccountConfig; + +export type MSTeamsWebhookConfig = { + /** Port for the webhook server. Default: 3978. */ + port?: number; + /** Path for the messages endpoint. Default: /api/messages. */ + path?: string; +}; + +/** Reply style for MS Teams messages. */ +export type MSTeamsReplyStyle = "thread" | "top-level"; + +/** Channel-level config for MS Teams. */ +export type MSTeamsChannelConfig = { + /** Require @mention to respond. Default: true. */ + requireMention?: boolean; + /** Reply style: "thread" replies to the message, "top-level" posts a new message. */ + replyStyle?: MSTeamsReplyStyle; +}; + +/** Team-level config for MS Teams. */ +export type MSTeamsTeamConfig = { + /** Default requireMention for channels in this team. */ + requireMention?: boolean; + /** Default reply style for channels in this team. */ + replyStyle?: MSTeamsReplyStyle; + /** Per-channel overrides. Key is conversation ID (e.g., "19:...@thread.tacv2"). */ + channels?: Record; +}; + +export type MSTeamsConfig = { + /** If false, do not start the MS Teams provider. Default: true. */ + enabled?: boolean; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** Azure Bot App ID (from Azure Bot registration). */ + appId?: string; + /** Azure Bot App Password / Client Secret. */ + appPassword?: string; + /** Azure AD Tenant ID (for single-tenant bots). */ + tenantId?: string; + /** Webhook server configuration. */ + webhook?: MSTeamsWebhookConfig; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; + /** Allowlist for DM senders (AAD object IDs or UPNs). */ + allowFrom?: Array; + /** Optional allowlist for group/channel senders (AAD object IDs or UPNs). */ + groupAllowFrom?: Array; + /** + * Controls how group/channel messages are handled: + * - "open": groups bypass allowFrom; mention-gating applies + * - "disabled": block all group messages + * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom + */ + groupPolicy?: GroupPolicy; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + /** + * Allowed host suffixes for inbound attachment downloads. + * Use ["*"] to allow any host (not recommended). + */ + mediaAllowHosts?: Array; + /** Default: require @mention to respond in channels/groups. */ + requireMention?: boolean; + /** Max group/channel messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; + /** Default reply style: "thread" replies to the message, "top-level" posts a new message. */ + replyStyle?: MSTeamsReplyStyle; + /** Per-team config. Key is team ID (from the /team/ URL path segment). */ + teams?: Record; +}; + +export type ChannelsConfig = { + whatsapp?: WhatsAppConfig; + telegram?: TelegramConfig; + discord?: DiscordConfig; + slack?: SlackConfig; + signal?: SignalConfig; + imessage?: IMessageConfig; + msteams?: MSTeamsConfig; +}; + +export type IMessageAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** If false, do not start this iMessage account. Default: true. */ + enabled?: boolean; + /** imsg CLI binary path (default: imsg). */ + cliPath?: string; + /** Optional Messages db path override. */ + dbPath?: string; + /** Optional default send service (imessage|sms|auto). */ + service?: "imessage" | "sms" | "auto"; + /** Optional default region (used when sending SMS). */ + region?: string; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; + /** Optional allowlist for inbound handles or chat_id targets. */ + allowFrom?: Array; + /** Optional allowlist for group senders or chat_id targets. */ + groupAllowFrom?: Array; + /** + * Controls how group messages are handled: + * - "open": groups bypass allowFrom; mention-gating applies + * - "disabled": block all group messages entirely + * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom + */ + groupPolicy?: GroupPolicy; + /** Max group messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; + /** Include attachments + reactions in watch payloads. */ + includeAttachments?: boolean; + /** Max outbound media size in MB. */ + mediaMaxMb?: number; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + blockStreaming?: boolean; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + groups?: Record< + string, + { + requireMention?: boolean; + } + >; +}; + +export type IMessageConfig = { + /** Optional per-account iMessage configuration (multi-account). */ + accounts?: Record; +} & IMessageAccountConfig; + +export type QueueMode = + | "steer" + | "followup" + | "collect" + | "steer-backlog" + | "steer+backlog" + | "queue" + | "interrupt"; +export type QueueDropPolicy = "old" | "new" | "summarize"; + +export type QueueModeByProvider = { + whatsapp?: QueueMode; + telegram?: QueueMode; + discord?: QueueMode; + slack?: QueueMode; + signal?: QueueMode; + imessage?: QueueMode; + msteams?: QueueMode; + webchat?: QueueMode; +}; + +export type SandboxDockerSettings = { + /** Docker image to use for sandbox containers. */ + image?: string; + /** Prefix for sandbox container names. */ + containerPrefix?: string; + /** Container workdir mount path (default: /workspace). */ + workdir?: string; + /** Run container rootfs read-only. */ + readOnlyRoot?: boolean; + /** Extra tmpfs mounts for read-only containers. */ + tmpfs?: string[]; + /** Container network mode (bridge|none|custom). */ + network?: string; + /** Container user (uid:gid). */ + user?: string; + /** Drop Linux capabilities. */ + capDrop?: string[]; + /** Extra environment variables for sandbox exec. */ + env?: Record; + /** Optional setup command run once after container creation. */ + setupCommand?: string; + /** Limit container PIDs (0 = Docker default). */ + pidsLimit?: number; + /** Limit container memory (e.g. 512m, 2g, or bytes as number). */ + memory?: string | number; + /** Limit container memory swap (same format as memory). */ + memorySwap?: string | number; + /** Limit container CPU shares (e.g. 0.5, 1, 2). */ + cpus?: number; + /** + * Set ulimit values by name (e.g. nofile, nproc). + * Use "soft:hard" string, a number, or { soft, hard }. + */ + ulimits?: Record; + /** Seccomp profile (path or profile name). */ + seccompProfile?: string; + /** AppArmor profile name. */ + apparmorProfile?: string; + /** DNS servers (e.g. ["1.1.1.1", "8.8.8.8"]). */ + dns?: string[]; + /** Extra host mappings (e.g. ["api.local:10.0.0.2"]). */ + extraHosts?: string[]; + /** Additional bind mounts (host:container:mode format, e.g. ["/host/path:/container/path:rw"]). */ + binds?: string[]; +}; + +export type SandboxBrowserSettings = { + enabled?: boolean; + image?: string; + containerPrefix?: string; + cdpPort?: number; + vncPort?: number; + noVncPort?: number; + headless?: boolean; + enableNoVnc?: boolean; + /** + * Allow sandboxed sessions to target the host browser control server. + * Default: false. + */ + allowHostControl?: boolean; + /** + * Allowlist of exact control URLs for target="custom". + * When set, any custom controlUrl must match this list. + */ + allowedControlUrls?: string[]; + /** + * Allowlist of hostnames for control URLs (hostname only, no ports). + * When set, controlUrl hostname must match. + */ + allowedControlHosts?: string[]; + /** + * Allowlist of ports for control URLs. + * When set, controlUrl port must match (defaults: http=80, https=443). + */ + allowedControlPorts?: number[]; + /** + * When true (default), sandboxed browser control will try to start/reattach to + * the sandbox browser container when a tool call needs it. + */ + autoStart?: boolean; + /** Max time to wait for CDP to become reachable after auto-start (ms). */ + autoStartTimeoutMs?: number; +}; + +export type SandboxPruneSettings = { + /** Prune if idle for more than N hours (0 disables). */ + idleHours?: number; + /** Prune if older than N days (0 disables). */ + maxAgeDays?: number; +}; + +export type GroupChatConfig = { + mentionPatterns?: string[]; + historyLimit?: number; +}; + +export type DmConfig = { + historyLimit?: number; +}; + +export type QueueConfig = { + mode?: QueueMode; + byChannel?: QueueModeByProvider; + debounceMs?: number; + cap?: number; + drop?: QueueDropPolicy; +}; + +export type ToolProfileId = "minimal" | "coding" | "messaging" | "full"; + +export type AgentToolsConfig = { + /** Base tool profile applied before allow/deny lists. */ + profile?: ToolProfileId; + allow?: string[]; + deny?: string[]; + /** Per-agent elevated exec gate (can only further restrict global tools.elevated). */ + elevated?: { + /** Enable or disable elevated mode for this agent (default: true). */ + enabled?: boolean; + /** Approved senders for /elevated (per-provider allowlists). */ + allowFrom?: AgentElevatedAllowFromConfig; + }; + sandbox?: { + tools?: { + allow?: string[]; + deny?: string[]; + }; + }; +}; + +export type MemorySearchConfig = { + /** Enable vector memory search (default: true). */ + enabled?: boolean; + /** Embedding provider mode. */ + provider?: "openai" | "local"; + remote?: { + baseUrl?: string; + apiKey?: string; + headers?: Record; + }; + /** Fallback behavior when local embeddings fail. */ + fallback?: "openai" | "none"; + /** Embedding model id (remote) or alias (local). */ + model?: string; + /** Local embedding settings (node-llama-cpp). */ + local?: { + /** GGUF model path or hf: URI. */ + modelPath?: string; + /** Optional cache directory for local models. */ + modelCacheDir?: string; + }; + /** Index storage configuration. */ + store?: { + driver?: "sqlite"; + path?: string; + }; + /** Chunking configuration. */ + chunking?: { + tokens?: number; + overlap?: number; + }; + /** Sync behavior. */ + sync?: { + onSessionStart?: boolean; + onSearch?: boolean; + watch?: boolean; + watchDebounceMs?: number; + intervalMinutes?: number; + }; + /** Query behavior. */ + query?: { + maxResults?: number; + minScore?: number; + }; +}; + +export type ToolsConfig = { + /** Base tool profile applied before allow/deny lists. */ + profile?: ToolProfileId; + allow?: string[]; + deny?: string[]; + audio?: { + transcription?: { + /** CLI args (template-enabled). */ + args?: string[]; + timeoutSeconds?: number; + }; + }; + agentToAgent?: { + /** Enable agent-to-agent messaging tools. Default: false. */ + enabled?: boolean; + /** Allowlist of agent ids or patterns (implementation-defined). */ + allow?: string[]; + }; + /** Elevated exec permissions for the host machine. */ + elevated?: { + /** Enable or disable elevated mode (default: true). */ + enabled?: boolean; + /** Approved senders for /elevated (per-provider allowlists). */ + allowFrom?: AgentElevatedAllowFromConfig; + }; + /** Exec tool defaults. */ + exec?: { + /** Default time (ms) before an exec command auto-backgrounds. */ + backgroundMs?: number; + /** Default timeout (seconds) before auto-killing exec commands. */ + timeoutSec?: number; + /** How long to keep finished sessions in memory (ms). */ + cleanupMs?: number; + /** apply_patch subtool configuration (experimental). */ + applyPatch?: { + /** Enable apply_patch for OpenAI models (default: false). */ + enabled?: boolean; + /** + * Optional allowlist of model ids that can use apply_patch. + * Accepts either raw ids (e.g. "gpt-5.2") or full ids (e.g. "openai/gpt-5.2"). + */ + allowModels?: string[]; + }; + }; + /** @deprecated Use tools.exec. */ + bash?: { + /** Default time (ms) before a bash command auto-backgrounds. */ + backgroundMs?: number; + /** Default timeout (seconds) before auto-killing bash commands. */ + timeoutSec?: number; + /** How long to keep finished sessions in memory (ms). */ + cleanupMs?: number; + }; + /** Sub-agent tool policy defaults (deny wins). */ + subagents?: { + /** Default model selection for spawned sub-agents (string or {primary,fallbacks}). */ + model?: string | { primary?: string; fallbacks?: string[] }; + tools?: { + allow?: string[]; + deny?: string[]; + }; + }; + /** Sandbox tool policy defaults (deny wins). */ + sandbox?: { + tools?: { + allow?: string[]; + deny?: string[]; + }; + }; +}; + +export type AgentModelConfig = + | string + | { + /** Primary model (provider/model). */ + primary?: string; + /** Per-agent model fallbacks (provider/model). */ + fallbacks?: string[]; + }; + +export type AgentConfig = { + id: string; + default?: boolean; + name?: string; + workspace?: string; + agentDir?: string; + model?: AgentModelConfig; + memorySearch?: MemorySearchConfig; + /** Human-like delay between block replies for this agent. */ + humanDelay?: HumanDelayConfig; + identity?: IdentityConfig; + groupChat?: GroupChatConfig; + subagents?: { + /** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */ + allowAgents?: string[]; + /** Per-agent default model for spawned sub-agents (string or {primary,fallbacks}). */ + model?: string | { primary?: string; fallbacks?: string[] }; + }; + sandbox?: { + mode?: "off" | "non-main" | "all"; + /** Agent workspace access inside the sandbox. */ + workspaceAccess?: "none" | "ro" | "rw"; + /** + * Session tools visibility for sandboxed sessions. + * - "spawned": only allow session tools to target sessions spawned from this session (default) + * - "all": allow session tools to target any session + */ + sessionToolsVisibility?: "spawned" | "all"; + /** Container/workspace scope for sandbox isolation. */ + scope?: "session" | "agent" | "shared"; + /** Legacy alias for scope ("session" when true, "shared" when false). */ + perSession?: boolean; + workspaceRoot?: string; + /** Docker-specific sandbox overrides for this agent. */ + docker?: SandboxDockerSettings; + /** Optional sandboxed browser overrides for this agent. */ + browser?: SandboxBrowserSettings; + /** Auto-prune overrides for this agent. */ + prune?: SandboxPruneSettings; + }; + tools?: AgentToolsConfig; +}; + +export type AgentsConfig = { + defaults?: AgentDefaultsConfig; + list?: AgentConfig[]; +}; + +export type AgentBinding = { + agentId: string; + match: { + channel: string; + accountId?: string; + peer?: { kind: "dm" | "group" | "channel"; id: string }; + guildId?: string; + teamId?: string; + }; +}; + +export type BroadcastStrategy = "parallel" | "sequential"; + +export type BroadcastConfig = { + /** Default processing strategy for broadcast peers. */ + strategy?: BroadcastStrategy; + /** + * Map peer IDs to arrays of agent IDs that should ALL process messages. + * + * Note: the index signature includes `undefined` so `strategy?: ...` remains type-safe. + */ + [peerId: string]: string[] | BroadcastStrategy | undefined; +}; + +export type AudioConfig = { + /** @deprecated Use tools.audio.transcription instead. */ + transcription?: { + // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. + command: string[]; + timeoutSeconds?: number; + }; +}; + +export type MessagesConfig = { + /** @deprecated Use `whatsapp.messagePrefix` (WhatsApp-only inbound prefix). */ + messagePrefix?: string; + /** + * Prefix auto-added to all outbound replies. + * - string: explicit prefix + * - special value: `"auto"` derives `[{agents.list[].identity.name}]` for the routed agent (when set) + * Default: none + */ + responsePrefix?: string; + groupChat?: GroupChatConfig; + queue?: QueueConfig; + /** Emoji reaction used to acknowledge inbound messages (empty disables). */ + ackReaction?: string; + /** When to send ack reactions. Default: "group-mentions". */ + ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all"; + /** Remove ack reaction after reply is sent (default: false). */ + removeAckAfterReply?: boolean; +}; + +export type NativeCommandsSetting = boolean | "auto"; + +export type CommandsConfig = { + /** Enable native command registration when supported (default: "auto"). */ + native?: NativeCommandsSetting; + /** Enable text command parsing (default: true). */ + text?: boolean; + /** Allow bash chat command (`!`; `/bash` alias) (default: false). */ + bash?: boolean; + /** How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately). */ + bashForegroundMs?: number; + /** Allow /config command (default: false). */ + config?: boolean; + /** Allow /debug command (default: false). */ + debug?: boolean; + /** Allow restart commands/tools (default: false). */ + restart?: boolean; + /** Enforce access-group allowlists/policies for commands (default: true). */ + useAccessGroups?: boolean; +}; + +export type ProviderCommandsConfig = { + /** Override native command registration for this provider (bool or "auto"). */ + native?: NativeCommandsSetting; +}; + +export type BridgeBindMode = "auto" | "lan" | "loopback" | "custom"; + +export type BridgeConfig = { + enabled?: boolean; + port?: number; + /** + * Bind address policy for the node bridge server. + * - auto: Tailnet IPv4 if available, else 0.0.0.0 (fallback to all interfaces) + * - lan: 0.0.0.0 (all interfaces, no fallback) + * - loopback: 127.0.0.1 (local-only) + * - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost on gateway) + */ + bind?: BridgeBindMode; +}; + +export type WideAreaDiscoveryConfig = { + enabled?: boolean; +}; + +export type DiscoveryConfig = { + wideArea?: WideAreaDiscoveryConfig; +}; + +export type CanvasHostConfig = { + enabled?: boolean; + /** Directory to serve (default: ~/clawd/canvas). */ + root?: string; + /** HTTP port to listen on (default: 18793). */ + port?: number; + /** Enable live-reload file watching + WS reloads (default: true). */ + liveReload?: boolean; +}; + +export type TalkConfig = { + /** Default ElevenLabs voice ID for Talk mode. */ + voiceId?: string; + /** Optional voice name -> ElevenLabs voice ID map. */ + voiceAliases?: Record; + /** Default ElevenLabs model ID for Talk mode. */ + modelId?: string; + /** Default ElevenLabs output format (e.g. mp3_44100_128). */ + outputFormat?: string; + /** ElevenLabs API key (optional; falls back to ELEVENLABS_API_KEY). */ + apiKey?: string; + /** Stop speaking when user starts talking (default: true). */ + interruptOnSpeech?: boolean; +}; + +export type GatewayControlUiConfig = { + /** If false, the Gateway will not serve the Control UI (default /). */ + enabled?: boolean; + /** Optional base path prefix for the Control UI (e.g. "/clawdbot"). */ + basePath?: string; +}; + +export type GatewayAuthMode = "token" | "password"; + +export type GatewayAuthConfig = { + /** Authentication mode for Gateway connections. Defaults to token when set. */ + mode?: GatewayAuthMode; + /** Shared token for token mode (stored locally for CLI auth). */ + token?: string; + /** Shared password for password mode (consider env instead). */ + password?: string; + /** Allow Tailscale identity headers when serve mode is enabled. */ + allowTailscale?: boolean; +}; + +export type GatewayTailscaleMode = "off" | "serve" | "funnel"; + +export type GatewayTailscaleConfig = { + /** Tailscale exposure mode for the Gateway control UI. */ + mode?: GatewayTailscaleMode; + /** Reset serve/funnel configuration on shutdown. */ + resetOnExit?: boolean; +}; + +export type GatewayRemoteConfig = { + /** Remote Gateway WebSocket URL (ws:// or wss://). */ + url?: string; + /** Token for remote auth (when the gateway requires token auth). */ + token?: string; + /** Password for remote auth (when the gateway requires password auth). */ + password?: string; + /** SSH target for tunneling remote Gateway (user@host). */ + sshTarget?: string; + /** SSH identity file path for tunneling remote Gateway. */ + sshIdentity?: string; +}; + +export type GatewayReloadMode = "off" | "restart" | "hot" | "hybrid"; + +export type GatewayReloadConfig = { + /** Reload strategy for config changes (default: hybrid). */ + mode?: GatewayReloadMode; + /** Debounce window for config reloads (ms). Default: 300. */ + debounceMs?: number; +}; + +export type GatewayHttpChatCompletionsConfig = { + /** + * If false, the Gateway will not serve `POST /v1/chat/completions`. + * Default: false when absent. + */ + enabled?: boolean; +}; + +export type GatewayHttpEndpointsConfig = { + chatCompletions?: GatewayHttpChatCompletionsConfig; +}; + +export type GatewayHttpConfig = { + endpoints?: GatewayHttpEndpointsConfig; +}; + +export type GatewayConfig = { + /** Single multiplexed port for Gateway WS + HTTP (default: 18789). */ + port?: number; + /** + * Explicit gateway mode. When set to "remote", local gateway start is disabled. + * When set to "local", the CLI may start the gateway locally. + */ + mode?: "local" | "remote"; + /** + * Bind address policy for the Gateway WebSocket + Control UI HTTP server. + * - auto: Tailnet IPv4 if available, else 0.0.0.0 (fallback to all interfaces) + * - lan: 0.0.0.0 (all interfaces, no fallback) + * - loopback: 127.0.0.1 (local-only) + * - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost) + * Default: loopback (127.0.0.1). + */ + bind?: BridgeBindMode; + /** Custom IP address for bind="custom" mode. Fallback: 0.0.0.0. */ + customBindHost?: string; + controlUi?: GatewayControlUiConfig; + auth?: GatewayAuthConfig; + tailscale?: GatewayTailscaleConfig; + remote?: GatewayRemoteConfig; + reload?: GatewayReloadConfig; + http?: GatewayHttpConfig; +}; + +export type SkillConfig = { + enabled?: boolean; + apiKey?: string; + env?: Record; + [key: string]: unknown; +}; + +export type SkillsLoadConfig = { + /** + * Additional skill folders to scan (lowest precedence). + * Each directory should contain skill subfolders with `SKILL.md`. + */ + extraDirs?: string[]; +}; + +export type SkillsInstallConfig = { + preferBrew?: boolean; + nodeManager?: "npm" | "pnpm" | "yarn" | "bun"; +}; + +export type SkillsConfig = { + /** Optional bundled-skill allowlist (only affects bundled skills). */ + allowBundled?: string[]; + load?: SkillsLoadConfig; + install?: SkillsInstallConfig; + entries?: Record; +}; + +export type PluginEntryConfig = { + enabled?: boolean; + config?: Record; +}; + +export type PluginsLoadConfig = { + /** Additional plugin/extension paths to load. */ + paths?: string[]; +}; + +export type PluginsConfig = { + /** Enable or disable plugin loading. */ + enabled?: boolean; + /** Optional plugin allowlist (plugin ids). */ + allow?: string[]; + /** Optional plugin denylist (plugin ids). */ + deny?: string[]; + load?: PluginsLoadConfig; + entries?: Record; +}; + +export type ModelApi = + | "openai-completions" + | "openai-responses" + | "anthropic-messages" + | "google-generative-ai" + | "github-copilot"; + +export type ModelCompatConfig = { + supportsStore?: boolean; + supportsDeveloperRole?: boolean; + supportsReasoningEffort?: boolean; + maxTokensField?: "max_completion_tokens" | "max_tokens"; +}; + +export type ModelDefinitionConfig = { + id: string; + name: string; + api?: ModelApi; + reasoning: boolean; + input: Array<"text" | "image">; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; + contextWindow: number; + maxTokens: number; + headers?: Record; + compat?: ModelCompatConfig; +}; + +export type ModelProviderConfig = { + baseUrl: string; + apiKey?: string; + api?: ModelApi; + headers?: Record; + authHeader?: boolean; + models: ModelDefinitionConfig[]; +}; + +export type ModelsConfig = { + mode?: "merge" | "replace"; + providers?: Record; +}; + +export type AuthProfileConfig = { + provider: string; + /** + * Credential type expected in auth-profiles.json for this profile id. + * - api_key: static provider API key + * - oauth: refreshable OAuth credentials (access+refresh+expires) + * - token: static bearer-style token (optionally expiring; no refresh) + */ + mode: "api_key" | "oauth" | "token"; + email?: string; +}; + +export type AuthConfig = { + profiles?: Record; + order?: Record; + cooldowns?: { + /** Default billing backoff (hours). Default: 5. */ + billingBackoffHours?: number; + /** Optional per-provider billing backoff (hours). */ + billingBackoffHoursByProvider?: Record; + /** Billing backoff cap (hours). Default: 24. */ + billingMaxHours?: number; + /** + * Failure window for backoff counters (hours). If no failures occur within + * this window, counters reset. Default: 24. + */ + failureWindowHours?: number; + }; +}; + +export type AgentModelEntryConfig = { + alias?: string; + /** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */ + params?: Record; +}; + +export type AgentModelListConfig = { + primary?: string; + fallbacks?: string[]; +}; + +export type AgentContextPruningConfig = { + mode?: "off" | "adaptive" | "aggressive"; + keepLastAssistants?: number; + softTrimRatio?: number; + hardClearRatio?: number; + minPrunableToolChars?: number; + tools?: { + allow?: string[]; + deny?: string[]; + }; + softTrim?: { + maxChars?: number; + headChars?: number; + tailChars?: number; + }; + hardClear?: { + enabled?: boolean; + placeholder?: string; + }; +}; + +export type CliBackendConfig = { + /** CLI command to execute (absolute path or on PATH). */ + command: string; + /** Base args applied to every invocation. */ + args?: string[]; + /** Output parsing mode (default: json). */ + output?: "json" | "text" | "jsonl"; + /** Output parsing mode when resuming a CLI session. */ + resumeOutput?: "json" | "text" | "jsonl"; + /** Prompt input mode (default: arg). */ + input?: "arg" | "stdin"; + /** Max prompt length for arg mode (if exceeded, stdin is used). */ + maxPromptArgChars?: number; + /** Extra env vars injected for this CLI. */ + env?: Record; + /** Env vars to remove before launching this CLI. */ + clearEnv?: string[]; + /** Flag used to pass model id (e.g. --model). */ + modelArg?: string; + /** Model aliases mapping (config model id → CLI model id). */ + modelAliases?: Record; + /** Flag used to pass session id (e.g. --session-id). */ + sessionArg?: string; + /** Extra args used when resuming a session (use {sessionId} placeholder). */ + sessionArgs?: string[]; + /** Alternate args to use when resuming a session (use {sessionId} placeholder). */ + resumeArgs?: string[]; + /** When to pass session ids. */ + sessionMode?: "always" | "existing" | "none"; + /** JSON fields to read session id from (in order). */ + sessionIdFields?: string[]; + /** Flag used to pass system prompt. */ + systemPromptArg?: string; + /** System prompt behavior (append vs replace). */ + systemPromptMode?: "append" | "replace"; + /** When to send system prompt. */ + systemPromptWhen?: "first" | "always" | "never"; + /** Flag used to pass image paths. */ + imageArg?: string; + /** How to pass multiple images. */ + imageMode?: "repeat" | "list"; + /** Serialize runs for this CLI. */ + serialize?: boolean; +}; + +export type AgentDefaultsConfig = { + /** Primary model and fallbacks (provider/model). */ + model?: AgentModelListConfig; + /** Optional image-capable model and fallbacks (provider/model). */ + imageModel?: AgentModelListConfig; + /** Model catalog with optional aliases (full provider/model keys). */ + models?: Record; + /** Agent working directory (preferred). Used as the default cwd for agent runs. */ + workspace?: string; + /** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */ + skipBootstrap?: boolean; + /** Max chars for injected bootstrap files before truncation (default: 20000). */ + bootstrapMaxChars?: number; + /** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */ + userTimezone?: string; + /** Optional display-only context window override (used for % in status UIs). */ + contextTokens?: number; + /** Optional CLI backends for text-only fallback (claude-cli, etc.). */ + cliBackends?: Record; + /** Opt-in: prune old tool results from the LLM context to reduce token usage. */ + contextPruning?: AgentContextPruningConfig; + /** Compaction tuning and pre-compaction memory flush behavior. */ + compaction?: AgentCompactionConfig; + /** Vector memory search configuration (per-agent overrides supported). */ + memorySearch?: MemorySearchConfig; + /** Default thinking level when no /think directive is present. */ + thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + /** Default verbose level when no /verbose directive is present. */ + verboseDefault?: "off" | "on"; + /** Default elevated level when no /elevated directive is present. */ + elevatedDefault?: "off" | "on"; + /** Default block streaming level when no override is present. */ + blockStreamingDefault?: "off" | "on"; + /** + * Block streaming boundary: + * - "text_end": end of each assistant text content block (before tool calls) + * - "message_end": end of the whole assistant message (may include tool blocks) + */ + blockStreamingBreak?: "text_end" | "message_end"; + /** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */ + blockStreamingChunk?: BlockStreamingChunkConfig; + /** + * Block reply coalescing (merge streamed chunks before send). + * idleMs: wait time before flushing when idle. + */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + /** Human-like delay between block replies. */ + humanDelay?: HumanDelayConfig; + timeoutSeconds?: number; + /** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */ + mediaMaxMb?: number; + typingIntervalSeconds?: number; + /** Typing indicator start mode (never|instant|thinking|message). */ + typingMode?: TypingMode; + /** Periodic background heartbeat runs. */ + heartbeat?: { + /** Heartbeat interval (duration string, default unit: minutes; default: 30m). */ + every?: string; + /** Heartbeat model override (provider/model). */ + model?: string; + /** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */ + target?: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "msteams" + | "signal" + | "imessage" + | "none"; + /** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */ + to?: string; + /** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time."). */ + prompt?: string; + /** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */ + ackMaxChars?: number; + /** + * When enabled, deliver the model's reasoning payload for heartbeat runs (when available) + * as a separate message prefixed with `Reasoning:` (same as `/reasoning on`). + * + * Default: false (only the final heartbeat payload is delivered). + */ + includeReasoning?: boolean; + }; + /** Max concurrent agent runs across all conversations. Default: 1 (sequential). */ + maxConcurrent?: number; + /** Sub-agent defaults (spawned via sessions_spawn). */ + subagents?: { + /** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */ + maxConcurrent?: number; + /** Auto-archive sub-agent sessions after N minutes (default: 60). */ + archiveAfterMinutes?: number; + /** Default model selection for spawned sub-agents (string or {primary,fallbacks}). */ + model?: string | { primary?: string; fallbacks?: string[] }; + }; + /** Optional sandbox settings for non-main sessions. */ + sandbox?: { + /** Enable sandboxing for sessions. */ + mode?: "off" | "non-main" | "all"; + /** + * Agent workspace access inside the sandbox. + * - "none": do not mount the agent workspace into the container; use a sandbox workspace under workspaceRoot + * - "ro": mount the agent workspace read-only; disables write/edit tools + * - "rw": mount the agent workspace read/write; enables write/edit tools + */ + workspaceAccess?: "none" | "ro" | "rw"; + /** + * Session tools visibility for sandboxed sessions. + * - "spawned": only allow session tools to target sessions spawned from this session (default) + * - "all": allow session tools to target any session + */ + sessionToolsVisibility?: "spawned" | "all"; + /** Container/workspace scope for sandbox isolation. */ + scope?: "session" | "agent" | "shared"; + /** Legacy alias for scope ("session" when true, "shared" when false). */ + perSession?: boolean; + /** Root directory for sandbox workspaces. */ + workspaceRoot?: string; + /** Docker-specific sandbox settings. */ + docker?: SandboxDockerSettings; + /** Optional sandboxed browser settings. */ + browser?: SandboxBrowserSettings; + /** Auto-prune sandbox containers. */ + prune?: SandboxPruneSettings; + }; +}; + +export type AgentCompactionMode = "default" | "safeguard"; + +export type AgentCompactionConfig = { + /** Compaction summarization mode. */ + mode?: AgentCompactionMode; + /** Minimum reserve tokens enforced for Pi compaction (0 disables the floor). */ + reserveTokensFloor?: number; + /** Pre-compaction memory flush (agentic turn). Default: enabled. */ + memoryFlush?: AgentCompactionMemoryFlushConfig; +}; + +export type AgentCompactionMemoryFlushConfig = { + /** Enable the pre-compaction memory flush (default: true). */ + enabled?: boolean; + /** Run the memory flush when context is within this many tokens of the compaction threshold. */ + softThresholdTokens?: number; + /** User prompt used for the memory flush turn (NO_REPLY is enforced if missing). */ + prompt?: string; + /** System prompt appended for the memory flush turn. */ + systemPrompt?: string; +}; + +export type ClawdbotConfig = { + auth?: AuthConfig; + env?: { + /** Opt-in: import missing secrets from a login shell environment (exec `$SHELL -l -c 'env -0'`). */ + shellEnv?: { + enabled?: boolean; + /** Timeout for the login shell exec (ms). Default: 15000. */ + timeoutMs?: number; + }; + /** Inline env vars to apply when not already present in the process env. */ + vars?: Record; + /** Sugar: allow env vars directly under env (string values only). */ + [key: string]: + | string + | Record + | { enabled?: boolean; timeoutMs?: number } + | undefined; + }; + wizard?: { + lastRunAt?: string; + lastRunVersion?: string; + lastRunCommit?: string; + lastRunCommand?: string; + lastRunMode?: "local" | "remote"; + }; + logging?: LoggingConfig; + browser?: BrowserConfig; + ui?: { + /** Accent color for Clawdbot UI chrome (hex). */ + seamColor?: string; + }; + skills?: SkillsConfig; + plugins?: PluginsConfig; + models?: ModelsConfig; + agents?: AgentsConfig; + tools?: ToolsConfig; + bindings?: AgentBinding[]; + broadcast?: BroadcastConfig; + audio?: AudioConfig; + messages?: MessagesConfig; + commands?: CommandsConfig; + session?: SessionConfig; + web?: WebConfig; + channels?: ChannelsConfig; + cron?: CronConfig; + hooks?: HooksConfig; + bridge?: BridgeConfig; + discovery?: DiscoveryConfig; + canvasHost?: CanvasHostConfig; + talk?: TalkConfig; + gateway?: GatewayConfig; +}; + +export type ConfigValidationIssue = { + path: string; + message: string; +}; + +export type LegacyConfigIssue = { + path: string; + message: string; +}; + +export type ConfigFileSnapshot = { + path: string; + exists: boolean; + raw: string | null; + parsed: unknown; + valid: boolean; + config: ClawdbotConfig; + issues: ConfigValidationIssue[]; + legacyIssues: LegacyConfigIssue[]; +}; \ No newline at end of file diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 0051bcae3..0e61fc460 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -6,6 +6,1451 @@ import { HookMappingSchema, HooksGmailSchema } from "./zod-schema.hooks.js"; import { ChannelsSchema } from "./zod-schema.providers.js"; import { CommandsSchema, MessagesSchema, SessionSchema } from "./zod-schema.session.js"; +const ModelApiSchema = z.union([ + z.literal("openai-completions"), + z.literal("openai-responses"), + z.literal("anthropic-messages"), + z.literal("google-generative-ai"), + z.literal("github-copilot"), +]); + +const ModelCompatSchema = z + .object({ + supportsStore: z.boolean().optional(), + supportsDeveloperRole: z.boolean().optional(), + supportsReasoningEffort: z.boolean().optional(), + maxTokensField: z + .union([z.literal("max_completion_tokens"), z.literal("max_tokens")]) + .optional(), + }) + .optional(); + +const ModelDefinitionSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + api: ModelApiSchema.optional(), + reasoning: z.boolean(), + input: z.array(z.union([z.literal("text"), z.literal("image")])), + cost: z.object({ + input: z.number(), + output: z.number(), + cacheRead: z.number(), + cacheWrite: z.number(), + }), + contextWindow: z.number().positive(), + maxTokens: z.number().positive(), + headers: z.record(z.string(), z.string()).optional(), + compat: ModelCompatSchema, +}); + +const ModelProviderSchema = z.object({ + baseUrl: z.string().min(1), + apiKey: z.string().optional(), + api: ModelApiSchema.optional(), + headers: z.record(z.string(), z.string()).optional(), + authHeader: z.boolean().optional(), + models: z.array(ModelDefinitionSchema), +}); + +const ModelsConfigSchema = z + .object({ + mode: z.union([z.literal("merge"), z.literal("replace")]).optional(), + providers: z.record(z.string(), ModelProviderSchema).optional(), + }) + .optional(); + +const GroupChatSchema = z + .object({ + mentionPatterns: z.array(z.string()).optional(), + historyLimit: z.number().int().positive().optional(), + }) + .optional(); + +const DmConfigSchema = z.object({ + historyLimit: z.number().int().min(0).optional(), +}); + +const IdentitySchema = z + .object({ + name: z.string().optional(), + theme: z.string().optional(), + emoji: z.string().optional(), + }) + .optional(); + +const QueueModeSchema = z.union([ + z.literal("steer"), + z.literal("followup"), + z.literal("collect"), + z.literal("steer-backlog"), + z.literal("steer+backlog"), + z.literal("queue"), + z.literal("interrupt"), +]); +const QueueDropSchema = z.union([ + z.literal("old"), + z.literal("new"), + z.literal("summarize"), +]); +const ReplyToModeSchema = z.union([ + z.literal("off"), + z.literal("first"), + z.literal("all"), +]); + +// GroupPolicySchema: controls how group messages are handled +// Used with .default("allowlist").optional() pattern: +// - .optional() allows field omission in input config +// - .default("allowlist") ensures runtime always resolves to "allowlist" if not provided +const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]); + +const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]); + +const BlockStreamingCoalesceSchema = z.object({ + minChars: z.number().int().positive().optional(), + maxChars: z.number().int().positive().optional(), + idleMs: z.number().int().nonnegative().optional(), +}); + +const BlockStreamingChunkSchema = z.object({ + minChars: z.number().int().positive().optional(), + maxChars: z.number().int().positive().optional(), + breakPreference: z + .union([ + z.literal("paragraph"), + z.literal("newline"), + z.literal("sentence"), + ]) + .optional(), +}); + +const HumanDelaySchema = z.object({ + mode: z + .union([z.literal("off"), z.literal("natural"), z.literal("custom")]) + .optional(), + minMs: z.number().int().nonnegative().optional(), + maxMs: z.number().int().nonnegative().optional(), +}); + +const CliBackendSchema = z.object({ + command: z.string(), + args: z.array(z.string()).optional(), + output: z + .union([z.literal("json"), z.literal("text"), z.literal("jsonl")]) + .optional(), + resumeOutput: z + .union([z.literal("json"), z.literal("text"), z.literal("jsonl")]) + .optional(), + input: z.union([z.literal("arg"), z.literal("stdin")]).optional(), + maxPromptArgChars: z.number().int().positive().optional(), + env: z.record(z.string(), z.string()).optional(), + clearEnv: z.array(z.string()).optional(), + modelArg: z.string().optional(), + modelAliases: z.record(z.string(), z.string()).optional(), + sessionArg: z.string().optional(), + sessionArgs: z.array(z.string()).optional(), + resumeArgs: z.array(z.string()).optional(), + sessionMode: z + .union([z.literal("always"), z.literal("existing"), z.literal("none")]) + .optional(), + sessionIdFields: z.array(z.string()).optional(), + systemPromptArg: z.string().optional(), + systemPromptMode: z + .union([z.literal("append"), z.literal("replace")]) + .optional(), + systemPromptWhen: z + .union([z.literal("first"), z.literal("always"), z.literal("never")]) + .optional(), + imageArg: z.string().optional(), + imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(), + serialize: z.boolean().optional(), +}); + +const normalizeAllowFrom = (values?: Array): string[] => + (values ?? []).map((v) => String(v).trim()).filter(Boolean); + +const requireOpenAllowFrom = (params: { + policy?: string; + allowFrom?: Array; + ctx: z.RefinementCtx; + path: Array; + message: string; +}) => { + if (params.policy !== "open") return; + const allow = normalizeAllowFrom(params.allowFrom); + if (allow.includes("*")) return; + params.ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: params.path, + message: params.message, + }); +}; + +const MSTeamsReplyStyleSchema = z.enum(["thread", "top-level"]); + +const RetryConfigSchema = z + .object({ + attempts: z.number().int().min(1).optional(), + minDelayMs: z.number().int().min(0).optional(), + maxDelayMs: z.number().int().min(0).optional(), + jitter: z.number().min(0).max(1).optional(), + }) + .optional(); + +const QueueModeBySurfaceSchema = z + .object({ + whatsapp: QueueModeSchema.optional(), + telegram: QueueModeSchema.optional(), + discord: QueueModeSchema.optional(), + slack: QueueModeSchema.optional(), + signal: QueueModeSchema.optional(), + imessage: QueueModeSchema.optional(), + msteams: QueueModeSchema.optional(), + webchat: QueueModeSchema.optional(), + }) + .optional(); + +const QueueSchema = z + .object({ + mode: QueueModeSchema.optional(), + byChannel: QueueModeBySurfaceSchema, + debounceMs: z.number().int().nonnegative().optional(), + cap: z.number().int().positive().optional(), + drop: QueueDropSchema.optional(), + }) + .optional(); + +const TranscribeAudioSchema = z + .object({ + command: z.array(z.string()).superRefine((value, ctx) => { + const executable = value[0]; + if (!isSafeExecutableValue(executable)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [0], + message: "expected safe executable name or path", + }); + } + }), + timeoutSeconds: z.number().int().positive().optional(), + }) + .optional(); + +const HexColorSchema = z + .string() + .regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)"); + +const ExecutableTokenSchema = z + .string() + .refine(isSafeExecutableValue, "expected safe executable name or path"); + +const ToolsAudioTranscriptionSchema = z + .object({ + args: z.array(z.string()).optional(), + timeoutSeconds: z.number().int().positive().optional(), + }) + .optional(); + +const NativeCommandsSettingSchema = z.union([z.boolean(), z.literal("auto")]); + +const ProviderCommandsSchema = z + .object({ + native: NativeCommandsSettingSchema.optional(), + }) + .optional(); + +const TelegramTopicSchema = z.object({ + requireMention: z.boolean().optional(), + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), +}); + +const TelegramGroupSchema = z.object({ + requireMention: z.boolean().optional(), + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), + topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(), +}); + +const TelegramAccountSchemaBase = z.object({ + name: z.string().optional(), + capabilities: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + commands: ProviderCommandsSchema, + dmPolicy: DmPolicySchema.optional().default("pairing"), + botToken: z.string().optional(), + tokenFile: z.string().optional(), + replyToMode: ReplyToModeSchema.optional(), + groups: z.record(z.string(), TelegramGroupSchema.optional()).optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + textChunkLimit: z.number().int().positive().optional(), + blockStreaming: z.boolean().optional(), + draftChunk: BlockStreamingChunkSchema.optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"), + reactionNotifications: z.enum(["off", "own", "all"]).optional(), + mediaMaxMb: z.number().positive().optional(), + retry: RetryConfigSchema, + proxy: z.string().optional(), + webhookUrl: z.string().optional(), + webhookSecret: z.string().optional(), + webhookPath: z.string().optional(), + actions: z + .object({ + reactions: z.boolean().optional(), + }) + .optional(), +}); + +const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine( + (value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"', + }); + }, +); + +const TelegramConfigSchema = TelegramAccountSchemaBase.extend({ + accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(), +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"', + }); +}); + +const DiscordDmSchema = z + .object({ + enabled: z.boolean().optional(), + policy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupEnabled: z.boolean().optional(), + groupChannels: z.array(z.union([z.string(), z.number()])).optional(), + }) + .superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.policy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.discord.dm.policy="open" requires channels.discord.dm.allowFrom to include "*"', + }); + }); + +const DiscordGuildChannelSchema = z.object({ + allow: z.boolean().optional(), + requireMention: z.boolean().optional(), + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + users: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), + autoThread: z.boolean().optional(), +}); + +const DiscordGuildSchema = z.object({ + slug: z.string().optional(), + requireMention: z.boolean().optional(), + reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), + users: z.array(z.union([z.string(), z.number()])).optional(), + channels: z + .record(z.string(), DiscordGuildChannelSchema.optional()) + .optional(), +}); + +const DiscordAccountSchema = z.object({ + name: z.string().optional(), + capabilities: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + commands: ProviderCommandsSchema, + token: z.string().optional(), + allowBots: z.boolean().optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + textChunkLimit: z.number().int().positive().optional(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + maxLinesPerMessage: z.number().int().positive().optional(), + mediaMaxMb: z.number().positive().optional(), + retry: RetryConfigSchema, + actions: z + .object({ + reactions: z.boolean().optional(), + stickers: z.boolean().optional(), + polls: z.boolean().optional(), + permissions: z.boolean().optional(), + messages: z.boolean().optional(), + threads: z.boolean().optional(), + pins: z.boolean().optional(), + search: z.boolean().optional(), + memberInfo: z.boolean().optional(), + roleInfo: z.boolean().optional(), + roles: z.boolean().optional(), + channelInfo: z.boolean().optional(), + voiceStatus: z.boolean().optional(), + events: z.boolean().optional(), + moderation: z.boolean().optional(), + }) + .optional(), + replyToMode: ReplyToModeSchema.optional(), + dm: DiscordDmSchema.optional(), + guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(), +}); + +const DiscordConfigSchema = DiscordAccountSchema.extend({ + accounts: z.record(z.string(), DiscordAccountSchema.optional()).optional(), +}); + +const SlackDmSchema = z + .object({ + enabled: z.boolean().optional(), + policy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupEnabled: z.boolean().optional(), + groupChannels: z.array(z.union([z.string(), z.number()])).optional(), + }) + .superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.policy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.slack.dm.policy="open" requires channels.slack.dm.allowFrom to include "*"', + }); + }); + +const SlackChannelSchema = z.object({ + enabled: z.boolean().optional(), + allow: z.boolean().optional(), + requireMention: z.boolean().optional(), + allowBots: z.boolean().optional(), + users: z.array(z.union([z.string(), z.number()])).optional(), + skills: z.array(z.string()).optional(), + systemPrompt: z.string().optional(), +}); + +const SlackAccountSchema = z.object({ + name: z.string().optional(), + capabilities: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + commands: ProviderCommandsSchema, + botToken: z.string().optional(), + appToken: z.string().optional(), + allowBots: z.boolean().optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + textChunkLimit: z.number().int().positive().optional(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + mediaMaxMb: z.number().positive().optional(), + reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), + reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(), + replyToMode: ReplyToModeSchema.optional(), + actions: z + .object({ + reactions: z.boolean().optional(), + messages: z.boolean().optional(), + pins: z.boolean().optional(), + search: z.boolean().optional(), + permissions: z.boolean().optional(), + memberInfo: z.boolean().optional(), + channelInfo: z.boolean().optional(), + emojiList: z.boolean().optional(), + }) + .optional(), + slashCommand: z + .object({ + enabled: z.boolean().optional(), + name: z.string().optional(), + sessionPrefix: z.string().optional(), + ephemeral: z.boolean().optional(), + }) + .optional(), + dm: SlackDmSchema.optional(), + channels: z.record(z.string(), SlackChannelSchema.optional()).optional(), +}); + +const SlackConfigSchema = SlackAccountSchema.extend({ + accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(), +}); + +const SignalAccountSchemaBase = z.object({ + name: z.string().optional(), + capabilities: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + account: z.string().optional(), + httpUrl: z.string().optional(), + httpHost: z.string().optional(), + httpPort: z.number().int().positive().optional(), + cliPath: ExecutableTokenSchema.optional(), + autoStart: z.boolean().optional(), + receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(), + ignoreAttachments: z.boolean().optional(), + ignoreStories: z.boolean().optional(), + sendReadReceipts: z.boolean().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + textChunkLimit: z.number().int().positive().optional(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + mediaMaxMb: z.number().int().positive().optional(), + reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), + reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(), +}); + +const SignalAccountSchema = SignalAccountSchemaBase.superRefine( + (value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.signal.dmPolicy="open" requires channels.signal.allowFrom to include "*"', + }); + }, +); + +const SignalConfigSchema = SignalAccountSchemaBase.extend({ + accounts: z.record(z.string(), SignalAccountSchema.optional()).optional(), +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.signal.dmPolicy="open" requires channels.signal.allowFrom to include "*"', + }); +}); + +const IMessageAccountSchemaBase = z.object({ + name: z.string().optional(), + capabilities: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + cliPath: ExecutableTokenSchema.optional(), + dbPath: z.string().optional(), + service: z + .union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]) + .optional(), + region: z.string().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + includeAttachments: z.boolean().optional(), + mediaMaxMb: z.number().int().positive().optional(), + textChunkLimit: z.number().int().positive().optional(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + groups: z + .record( + z.string(), + z + .object({ + requireMention: z.boolean().optional(), + }) + .optional(), + ) + .optional(), +}); + +const IMessageAccountSchema = IMessageAccountSchemaBase.superRefine( + (value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.imessage.dmPolicy="open" requires channels.imessage.allowFrom to include "*"', + }); + }, +); + +const IMessageConfigSchema = IMessageAccountSchemaBase.extend({ + accounts: z.record(z.string(), IMessageAccountSchema.optional()).optional(), +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.imessage.dmPolicy="open" requires channels.imessage.allowFrom to include "*"', + }); +}); + +const MSTeamsChannelSchema = z.object({ + requireMention: z.boolean().optional(), + replyStyle: MSTeamsReplyStyleSchema.optional(), +}); + +const MSTeamsTeamSchema = z.object({ + requireMention: z.boolean().optional(), + replyStyle: MSTeamsReplyStyleSchema.optional(), + channels: z.record(z.string(), MSTeamsChannelSchema.optional()).optional(), +}); + +const MSTeamsConfigSchema = z + .object({ + enabled: z.boolean().optional(), + capabilities: z.array(z.string()).optional(), + appId: z.string().optional(), + appPassword: z.string().optional(), + tenantId: z.string().optional(), + webhook: z + .object({ + port: z.number().int().positive().optional(), + path: z.string().optional(), + }) + .optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.string()).optional(), + groupAllowFrom: z.array(z.string()).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + textChunkLimit: z.number().int().positive().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + mediaAllowHosts: z.array(z.string()).optional(), + requireMention: z.boolean().optional(), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + replyStyle: MSTeamsReplyStyleSchema.optional(), + teams: z.record(z.string(), MSTeamsTeamSchema.optional()).optional(), + }) + .superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.msteams.dmPolicy="open" requires channels.msteams.allowFrom to include "*"', + }); + }); + +const WhatsAppAccountSchema = z + .object({ + name: z.string().optional(), + capabilities: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + messagePrefix: z.string().optional(), + /** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */ + authDir: z.string().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + selfChatMode: z.boolean().optional(), + allowFrom: z.array(z.string()).optional(), + groupAllowFrom: z.array(z.string()).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + textChunkLimit: z.number().int().positive().optional(), + mediaMaxMb: z.number().int().positive().optional(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + groups: z + .record( + z.string(), + z + .object({ + requireMention: z.boolean().optional(), + }) + .optional(), + ) + .optional(), + ackReaction: z + .object({ + emoji: z.string().optional(), + direct: z.boolean().optional().default(true), + group: z + .enum(["always", "mentions", "never"]) + .optional() + .default("mentions"), + }) + .optional(), + }) + .superRefine((value, ctx) => { + if (value.dmPolicy !== "open") return; + const allow = (value.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean); + if (allow.includes("*")) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'channels.whatsapp.accounts.*.dmPolicy="open" requires allowFrom to include "*"', + }); + }); + +const WhatsAppConfigSchema = z + .object({ + accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(), + capabilities: z.array(z.string()).optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + messagePrefix: z.string().optional(), + selfChatMode: z.boolean().optional(), + allowFrom: z.array(z.string()).optional(), + groupAllowFrom: z.array(z.string()).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + textChunkLimit: z.number().int().positive().optional(), + mediaMaxMb: z.number().int().positive().optional().default(50), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + actions: z + .object({ + reactions: z.boolean().optional(), + sendMessage: z.boolean().optional(), + polls: z.boolean().optional(), + }) + .optional(), + groups: z + .record( + z.string(), + z + .object({ + requireMention: z.boolean().optional(), + }) + .optional(), + ) + .optional(), + ackReaction: z + .object({ + emoji: z.string().optional(), + direct: z.boolean().optional().default(true), + group: z + .enum(["always", "mentions", "never"]) + .optional() + .default("mentions"), + }) + .optional(), + }) + .superRefine((value, ctx) => { + if (value.dmPolicy !== "open") return; + const allow = (value.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean); + if (allow.includes("*")) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'channels.whatsapp.dmPolicy="open" requires channels.whatsapp.allowFrom to include "*"', + }); + }); + +const ChannelsSchema = z + .object({ + whatsapp: WhatsAppConfigSchema.optional(), + telegram: TelegramConfigSchema.optional(), + discord: DiscordConfigSchema.optional(), + slack: SlackConfigSchema.optional(), + signal: SignalConfigSchema.optional(), + imessage: IMessageConfigSchema.optional(), + msteams: MSTeamsConfigSchema.optional(), + }) + .optional(); + +const SessionSchema = z + .object({ + scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(), + resetTriggers: z.array(z.string()).optional(), + idleMinutes: z.number().int().positive().optional(), + heartbeatIdleMinutes: z.number().int().positive().optional(), + store: z.string().optional(), + typingIntervalSeconds: z.number().int().positive().optional(), + typingMode: z + .union([ + z.literal("never"), + z.literal("instant"), + z.literal("thinking"), + z.literal("message"), + ]) + .optional(), + mainKey: z.string().optional(), + sendPolicy: z + .object({ + default: z.union([z.literal("allow"), z.literal("deny")]).optional(), + rules: z + .array( + z.object({ + action: z.union([z.literal("allow"), z.literal("deny")]), + match: z + .object({ + channel: z.string().optional(), + chatType: z + .union([ + z.literal("direct"), + z.literal("group"), + z.literal("room"), + ]) + .optional(), + keyPrefix: z.string().optional(), + }) + .optional(), + }), + ) + .optional(), + }) + .optional(), + agentToAgent: z + .object({ + maxPingPongTurns: z.number().int().min(0).max(5).optional(), + }) + .optional(), + }) + .optional(); + +const MessagesSchema = z + .object({ + messagePrefix: z.string().optional(), + responsePrefix: z.string().optional(), + groupChat: GroupChatSchema, + queue: QueueSchema, + ackReaction: z.string().optional(), + ackReactionScope: z + .enum(["group-mentions", "group-all", "direct", "all"]) + .optional(), + removeAckAfterReply: z.boolean().optional(), + }) + .optional(); + +const CommandsSchema = z + .object({ + native: NativeCommandsSettingSchema.optional().default("auto"), + text: z.boolean().optional(), + bash: z.boolean().optional(), + bashForegroundMs: z.number().int().min(0).max(30_000).optional(), + config: z.boolean().optional(), + debug: z.boolean().optional(), + restart: z.boolean().optional(), + useAccessGroups: z.boolean().optional(), + }) + .optional() + .default({ native: "auto" }); + +const HeartbeatSchema = z + .object({ + every: z.string().optional(), + model: z.string().optional(), + includeReasoning: z.boolean().optional(), + target: z + .union([ + z.literal("last"), + z.literal("whatsapp"), + z.literal("telegram"), + z.literal("discord"), + z.literal("slack"), + z.literal("msteams"), + z.literal("signal"), + z.literal("imessage"), + z.literal("none"), + ]) + .optional(), + to: z.string().optional(), + prompt: z.string().optional(), + ackMaxChars: z.number().int().nonnegative().optional(), + }) + .superRefine((val, ctx) => { + if (!val.every) return; + try { + parseDurationMs(val.every, { defaultUnit: "m" }); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["every"], + message: "invalid duration (use ms, s, m, h)", + }); + } + }) + .optional(); + +const SandboxDockerSchema = z + .object({ + image: z.string().optional(), + containerPrefix: z.string().optional(), + workdir: z.string().optional(), + readOnlyRoot: z.boolean().optional(), + tmpfs: z.array(z.string()).optional(), + network: z.string().optional(), + user: z.string().optional(), + capDrop: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + setupCommand: z.string().optional(), + pidsLimit: z.number().int().positive().optional(), + memory: z.union([z.string(), z.number()]).optional(), + memorySwap: z.union([z.string(), z.number()]).optional(), + cpus: z.number().positive().optional(), + ulimits: z + .record( + z.string(), + z.union([ + z.string(), + z.number(), + z.object({ + soft: z.number().int().nonnegative().optional(), + hard: z.number().int().nonnegative().optional(), + }), + ]), + ) + .optional(), + seccompProfile: z.string().optional(), + apparmorProfile: z.string().optional(), + dns: z.array(z.string()).optional(), + extraHosts: z.array(z.string()).optional(), + }) + .optional(); + +const SandboxBrowserSchema = z + .object({ + enabled: z.boolean().optional(), + image: z.string().optional(), + containerPrefix: z.string().optional(), + cdpPort: z.number().int().positive().optional(), + vncPort: z.number().int().positive().optional(), + noVncPort: z.number().int().positive().optional(), + headless: z.boolean().optional(), + enableNoVnc: z.boolean().optional(), + allowHostControl: z.boolean().optional(), + allowedControlUrls: z.array(z.string()).optional(), + allowedControlHosts: z.array(z.string()).optional(), + allowedControlPorts: z.array(z.number().int().positive()).optional(), + autoStart: z.boolean().optional(), + autoStartTimeoutMs: z.number().int().positive().optional(), + }) + .optional(); + +const SandboxPruneSchema = z + .object({ + idleHours: z.number().int().nonnegative().optional(), + maxAgeDays: z.number().int().nonnegative().optional(), + }) + .optional(); + +const ToolPolicySchema = z + .object({ + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), + }) + .optional(); + +const ToolProfileSchema = z + .union([ + z.literal("minimal"), + z.literal("coding"), + z.literal("messaging"), + z.literal("full"), + ]) + .optional(); + +// Provider docking: allowlists keyed by provider id (no schema updates when adding providers). +const ElevatedAllowFromSchema = z + .record(z.string(), z.array(z.union([z.string(), z.number()]))) + .optional(); + +const AgentSandboxSchema = z + .object({ + mode: z + .union([z.literal("off"), z.literal("non-main"), z.literal("all")]) + .optional(), + workspaceAccess: z + .union([z.literal("none"), z.literal("ro"), z.literal("rw")]) + .optional(), + sessionToolsVisibility: z + .union([z.literal("spawned"), z.literal("all")]) + .optional(), + scope: z + .union([z.literal("session"), z.literal("agent"), z.literal("shared")]) + .optional(), + perSession: z.boolean().optional(), + workspaceRoot: z.string().optional(), + docker: SandboxDockerSchema, + browser: SandboxBrowserSchema, + prune: SandboxPruneSchema, + }) + .optional(); + +const AgentToolsSchema = z + .object({ + profile: ToolProfileSchema, + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), + elevated: z + .object({ + enabled: z.boolean().optional(), + allowFrom: ElevatedAllowFromSchema, + }) + .optional(), + sandbox: z + .object({ + tools: ToolPolicySchema, + }) + .optional(), + }) + .optional(); + +const MemorySearchSchema = z + .object({ + enabled: z.boolean().optional(), + provider: z.union([z.literal("openai"), z.literal("local")]).optional(), + remote: z + .object({ + baseUrl: z.string().optional(), + apiKey: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + }) + .optional(), + fallback: z.union([z.literal("openai"), z.literal("none")]).optional(), + model: z.string().optional(), + local: z + .object({ + modelPath: z.string().optional(), + modelCacheDir: z.string().optional(), + }) + .optional(), + store: z + .object({ + driver: z.literal("sqlite").optional(), + path: z.string().optional(), + }) + .optional(), + chunking: z + .object({ + tokens: z.number().int().positive().optional(), + overlap: z.number().int().nonnegative().optional(), + }) + .optional(), + sync: z + .object({ + onSessionStart: z.boolean().optional(), + onSearch: z.boolean().optional(), + watch: z.boolean().optional(), + watchDebounceMs: z.number().int().nonnegative().optional(), + intervalMinutes: z.number().int().nonnegative().optional(), + }) + .optional(), + query: z + .object({ + maxResults: z.number().int().positive().optional(), + minScore: z.number().min(0).max(1).optional(), + }) + .optional(), + }) + .optional(); +const AgentModelSchema = z.union([ + z.string(), + z.object({ + primary: z.string().optional(), + fallbacks: z.array(z.string()).optional(), + }), +]); +const AgentEntrySchema = z.object({ + id: z.string(), + default: z.boolean().optional(), + name: z.string().optional(), + workspace: z.string().optional(), + agentDir: z.string().optional(), + model: AgentModelSchema.optional(), + memorySearch: MemorySearchSchema, + humanDelay: HumanDelaySchema.optional(), + identity: IdentitySchema, + groupChat: GroupChatSchema, + subagents: z + .object({ + allowAgents: z.array(z.string()).optional(), + model: z + .union([ + z.string(), + z.object({ + primary: z.string().optional(), + fallbacks: z.array(z.string()).optional(), + }), + ]) + .optional(), + }) + .optional(), + sandbox: AgentSandboxSchema, + tools: AgentToolsSchema, +}); + +const ToolsSchema = z + .object({ + profile: ToolProfileSchema, + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), + audio: z + .object({ + transcription: ToolsAudioTranscriptionSchema, + }) + .optional(), + agentToAgent: z + .object({ + enabled: z.boolean().optional(), + allow: z.array(z.string()).optional(), + }) + .optional(), + elevated: z + .object({ + enabled: z.boolean().optional(), + allowFrom: ElevatedAllowFromSchema, + }) + .optional(), + exec: z + .object({ + backgroundMs: z.number().int().positive().optional(), + timeoutSec: z.number().int().positive().optional(), + cleanupMs: z.number().int().positive().optional(), + applyPatch: z + .object({ + enabled: z.boolean().optional(), + allowModels: z.array(z.string()).optional(), + }) + .optional(), + }) + .optional(), + bash: z + .object({ + backgroundMs: z.number().int().positive().optional(), + timeoutSec: z.number().int().positive().optional(), + cleanupMs: z.number().int().positive().optional(), + }) + .optional(), + subagents: z + .object({ + tools: ToolPolicySchema, + }) + .optional(), + sandbox: z + .object({ + tools: ToolPolicySchema, + }) + .optional(), + }) + .optional(); + +const AgentsSchema = z + .object({ + defaults: z.lazy(() => AgentDefaultsSchema).optional(), + list: z.array(AgentEntrySchema).optional(), + }) + .optional(); + +const BindingsSchema = z + .array( + z.object({ + agentId: z.string(), + match: z.object({ + channel: z.string(), + accountId: z.string().optional(), + peer: z + .object({ + kind: z.union([ + z.literal("dm"), + z.literal("group"), + z.literal("channel"), + ]), + id: z.string(), + }) + .optional(), + guildId: z.string().optional(), + teamId: z.string().optional(), + }), + }), + ) + .optional(); + +const BroadcastStrategySchema = z.enum(["parallel", "sequential"]); + +const BroadcastSchema = z + .object({ + strategy: BroadcastStrategySchema.optional(), + }) + .catchall(z.array(z.string())) + .optional(); + +const AudioSchema = z + .object({ + transcription: TranscribeAudioSchema, + }) + .optional(); + +const HookMappingSchema = z + .object({ + id: z.string().optional(), + match: z + .object({ + path: z.string().optional(), + source: z.string().optional(), + }) + .optional(), + action: z.union([z.literal("wake"), z.literal("agent")]).optional(), + wakeMode: z + .union([z.literal("now"), z.literal("next-heartbeat")]) + .optional(), + name: z.string().optional(), + sessionKey: z.string().optional(), + messageTemplate: z.string().optional(), + textTemplate: z.string().optional(), + deliver: z.boolean().optional(), + channel: z + .union([ + z.literal("last"), + z.literal("whatsapp"), + z.literal("telegram"), + z.literal("discord"), + z.literal("slack"), + z.literal("signal"), + z.literal("imessage"), + z.literal("msteams"), + ]) + .optional(), + to: z.string().optional(), + model: z.string().optional(), + thinking: z.string().optional(), + timeoutSeconds: z.number().int().positive().optional(), + transform: z + .object({ + module: z.string(), + export: z.string().optional(), + }) + .optional(), + }) + .optional(); + +const HooksGmailSchema = z + .object({ + account: z.string().optional(), + label: z.string().optional(), + topic: z.string().optional(), + subscription: z.string().optional(), + pushToken: z.string().optional(), + hookUrl: z.string().optional(), + includeBody: z.boolean().optional(), + maxBytes: z.number().int().positive().optional(), + renewEveryMinutes: z.number().int().positive().optional(), + serve: z + .object({ + bind: z.string().optional(), + port: z.number().int().positive().optional(), + path: z.string().optional(), + }) + .optional(), + tailscale: z + .object({ + mode: z + .union([z.literal("off"), z.literal("serve"), z.literal("funnel")]) + .optional(), + path: z.string().optional(), + target: z.string().optional(), + }) + .optional(), + model: z.string().optional(), + thinking: z + .union([ + z.literal("off"), + z.literal("minimal"), + z.literal("low"), + z.literal("medium"), + z.literal("high"), + ]) + .optional(), + }) + .optional(); + +const AgentDefaultsSchema = z + .object({ + model: z + .object({ + primary: z.string().optional(), + fallbacks: z.array(z.string()).optional(), + }) + .optional(), + imageModel: z + .object({ + primary: z.string().optional(), + fallbacks: z.array(z.string()).optional(), + }) + .optional(), + models: z + .record( + z.string(), + z.object({ + alias: z.string().optional(), + /** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */ + params: z.record(z.string(), z.unknown()).optional(), + }), + ) + .optional(), + workspace: z.string().optional(), + skipBootstrap: z.boolean().optional(), + bootstrapMaxChars: z.number().int().positive().optional(), + userTimezone: z.string().optional(), + contextTokens: z.number().int().positive().optional(), + cliBackends: z.record(z.string(), CliBackendSchema).optional(), + memorySearch: MemorySearchSchema, + contextPruning: z + .object({ + mode: z + .union([ + z.literal("off"), + z.literal("adaptive"), + z.literal("aggressive"), + ]) + .optional(), + keepLastAssistants: z.number().int().nonnegative().optional(), + softTrimRatio: z.number().min(0).max(1).optional(), + hardClearRatio: z.number().min(0).max(1).optional(), + minPrunableToolChars: z.number().int().nonnegative().optional(), + tools: z + .object({ + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), + }) + .optional(), + softTrim: z + .object({ + maxChars: z.number().int().nonnegative().optional(), + headChars: z.number().int().nonnegative().optional(), + tailChars: z.number().int().nonnegative().optional(), + }) + .optional(), + hardClear: z + .object({ + enabled: z.boolean().optional(), + placeholder: z.string().optional(), + }) + .optional(), + }) + .optional(), + compaction: z + .object({ + mode: z + .union([z.literal("default"), z.literal("safeguard")]) + .optional(), + reserveTokensFloor: z.number().int().nonnegative().optional(), + memoryFlush: z + .object({ + enabled: z.boolean().optional(), + softThresholdTokens: z.number().int().nonnegative().optional(), + prompt: z.string().optional(), + systemPrompt: z.string().optional(), + }) + .optional(), + }) + .optional(), + thinkingDefault: z + .union([ + z.literal("off"), + z.literal("minimal"), + z.literal("low"), + z.literal("medium"), + z.literal("high"), + z.literal("xhigh"), + ]) + .optional(), + verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(), + elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(), + blockStreamingDefault: z + .union([z.literal("off"), z.literal("on")]) + .optional(), + blockStreamingBreak: z + .union([z.literal("text_end"), z.literal("message_end")]) + .optional(), + blockStreamingChunk: BlockStreamingChunkSchema.optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + humanDelay: HumanDelaySchema.optional(), + timeoutSeconds: z.number().int().positive().optional(), + mediaMaxMb: z.number().positive().optional(), + typingIntervalSeconds: z.number().int().positive().optional(), + typingMode: z + .union([ + z.literal("never"), + z.literal("instant"), + z.literal("thinking"), + z.literal("message"), + ]) + .optional(), + heartbeat: HeartbeatSchema, + maxConcurrent: z.number().int().positive().optional(), + subagents: z + .object({ + maxConcurrent: z.number().int().positive().optional(), + archiveAfterMinutes: z.number().int().positive().optional(), + model: z + .union([ + z.string(), + z.object({ + primary: z.string().optional(), + fallbacks: z.array(z.string()).optional(), + }), + ]) + .optional(), + }) + .optional(), + sandbox: z + .object({ + mode: z + .union([z.literal("off"), z.literal("non-main"), z.literal("all")]) + .optional(), + workspaceAccess: z + .union([z.literal("none"), z.literal("ro"), z.literal("rw")]) + .optional(), + sessionToolsVisibility: z + .union([z.literal("spawned"), z.literal("all")]) + .optional(), + scope: z + .union([ + z.literal("session"), + z.literal("agent"), + z.literal("shared"), + ]) + .optional(), + perSession: z.boolean().optional(), + workspaceRoot: z.string().optional(), + docker: SandboxDockerSchema, + browser: SandboxBrowserSchema, + prune: SandboxPruneSchema, + }) + .optional(), + }) + .optional(); + export const ClawdbotSchema = z .object({ env: z diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts new file mode 100644 index 000000000..0a8882fa2 --- /dev/null +++ b/src/telegram/bot.test.ts @@ -0,0 +1,2411 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; +import * as replyModule from "../auto-reply/reply.js"; +import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; +import { resolveTelegramFetch } from "./fetch.js"; + +const { loadWebMedia } = vi.hoisted(() => ({ + loadWebMedia: vi.fn(), +})); + +vi.mock("../web/media.js", () => ({ + loadWebMedia, +})); + +const { loadConfig } = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({})), +})); +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig, + }; +}); + +const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted( + () => ({ + readTelegramAllowFromStore: vi.fn(async () => [] as string[]), + upsertTelegramPairingRequest: vi.fn(async () => ({ + code: "PAIRCODE", + created: true, + })), + }), +); + +vi.mock("./pairing-store.js", () => ({ + readTelegramAllowFromStore, + upsertTelegramPairingRequest, +})); + +const { enqueueSystemEvent } = vi.hoisted(() => ({ + enqueueSystemEvent: vi.fn(), +})); +vi.mock("../infra/system-events.js", () => ({ + enqueueSystemEvent, +})); + +const { wasSentByBot } = vi.hoisted(() => ({ + wasSentByBot: vi.fn(() => false), +})); +vi.mock("./sent-message-cache.js", () => ({ + wasSentByBot, + recordSentMessage: vi.fn(), + clearSentMessageCache: vi.fn(), +})); + +const useSpy = vi.fn(); +const middlewareUseSpy = vi.fn(); +const onSpy = vi.fn(); +const stopSpy = vi.fn(); +const commandSpy = vi.fn(); +const botCtorSpy = vi.fn(); +const answerCallbackQuerySpy = vi.fn(async () => undefined); +const sendChatActionSpy = vi.fn(); +const setMessageReactionSpy = vi.fn(async () => undefined); +const setMyCommandsSpy = vi.fn(async () => undefined); +const sendMessageSpy = vi.fn(async () => ({ message_id: 77 })); +const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 })); +const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 })); +type ApiStub = { + config: { use: (arg: unknown) => void }; + answerCallbackQuery: typeof answerCallbackQuerySpy; + sendChatAction: typeof sendChatActionSpy; + setMessageReaction: typeof setMessageReactionSpy; + setMyCommands: typeof setMyCommandsSpy; + sendMessage: typeof sendMessageSpy; + sendAnimation: typeof sendAnimationSpy; + sendPhoto: typeof sendPhotoSpy; +}; +const apiStub: ApiStub = { + config: { use: useSpy }, + answerCallbackQuery: answerCallbackQuerySpy, + sendChatAction: sendChatActionSpy, + setMessageReaction: setMessageReactionSpy, + setMyCommands: setMyCommandsSpy, + sendMessage: sendMessageSpy, + sendAnimation: sendAnimationSpy, + sendPhoto: sendPhotoSpy, +}; + +vi.mock("grammy", () => ({ + Bot: class { + api = apiStub; + use = middlewareUseSpy; + on = onSpy; + stop = stopSpy; + command = commandSpy; + constructor( + public token: string, + public options?: { client?: { fetch?: typeof fetch } }, + ) { + botCtorSpy(token, options); + } + }, + InputFile: class {}, + webhookCallback: vi.fn(), +})); + +const sequentializeMiddleware = vi.fn(); +const sequentializeSpy = vi.fn(() => sequentializeMiddleware); +let sequentializeKey: ((ctx: unknown) => string) | undefined; +vi.mock("@grammyjs/runner", () => ({ + sequentialize: (keyFn: (ctx: unknown) => string) => { + sequentializeKey = keyFn; + return sequentializeSpy(); + }, +})); + +const throttlerSpy = vi.fn(() => "throttler"); + +vi.mock("@grammyjs/transformer-throttler", () => ({ + apiThrottler: () => throttlerSpy(), +})); + +vi.mock("../auto-reply/reply.js", () => { + const replySpy = vi.fn(async (_ctx, opts) => { + await opts?.onReplyStart?.(); + return undefined; + }); + return { getReplyFromConfig: replySpy, __replySpy: replySpy }; +}); + +const getOnHandler = (event: string) => { + const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; + if (!handler) throw new Error(`Missing handler for event: ${event}`); + return handler as (ctx: Record) => Promise; +}; + +describe("createTelegramBot", () => { + beforeEach(() => { + resetInboundDedupe(); + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + }); + loadWebMedia.mockReset(); + sendAnimationSpy.mockReset(); + sendPhotoSpy.mockReset(); + setMessageReactionSpy.mockReset(); + answerCallbackQuerySpy.mockReset(); + setMyCommandsSpy.mockReset(); + middlewareUseSpy.mockReset(); + sequentializeSpy.mockReset(); + botCtorSpy.mockReset(); + sequentializeKey = undefined; + }); + + it("installs grammY throttler", () => { + createTelegramBot({ token: "tok" }); + expect(throttlerSpy).toHaveBeenCalledTimes(1); + expect(useSpy).toHaveBeenCalledWith("throttler"); + }); + + it("forces native fetch only under Bun", () => { + const originalFetch = globalThis.fetch; + const originalBun = (globalThis as { Bun?: unknown }).Bun; + const fetchSpy = vi.fn() as unknown as typeof fetch; + globalThis.fetch = fetchSpy; + try { + (globalThis as { Bun?: unknown }).Bun = {}; + createTelegramBot({ token: "tok" }); + const fetchImpl = resolveTelegramFetch(); + expect(fetchImpl).toBe(fetchSpy); + expect(botCtorSpy).toHaveBeenCalledWith( + "tok", + expect.objectContaining({ + client: expect.objectContaining({ fetch: fetchSpy }), + }), + ); + } finally { + globalThis.fetch = originalFetch; + if (originalBun === undefined) { + delete (globalThis as { Bun?: unknown }).Bun; + } else { + (globalThis as { Bun?: unknown }).Bun = originalBun; + } + } + }); + + it("does not force native fetch on Node", () => { + const originalFetch = globalThis.fetch; + const originalBun = (globalThis as { Bun?: unknown }).Bun; + const fetchSpy = vi.fn() as unknown as typeof fetch; + globalThis.fetch = fetchSpy; + try { + if (originalBun !== undefined) { + delete (globalThis as { Bun?: unknown }).Bun; + } + createTelegramBot({ token: "tok" }); + const fetchImpl = resolveTelegramFetch(); + expect(fetchImpl).toBeUndefined(); + expect(botCtorSpy).toHaveBeenCalledWith("tok", undefined); + } finally { + globalThis.fetch = originalFetch; + if (originalBun === undefined) { + delete (globalThis as { Bun?: unknown }).Bun; + } else { + (globalThis as { Bun?: unknown }).Bun = originalBun; + } + } + }); + + it("sequentializes updates by chat and thread", () => { + createTelegramBot({ token: "tok" }); + expect(sequentializeSpy).toHaveBeenCalledTimes(1); + expect(middlewareUseSpy).toHaveBeenCalledWith( + sequentializeSpy.mock.results[0]?.value, + ); + expect(sequentializeKey).toBe(getTelegramSequentialKey); + expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe( + "telegram:123", + ); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123 }, message_thread_id: 9 }, + }), + ).toBe("telegram:123:topic:9"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123, is_forum: true } }, + }), + ).toBe("telegram:123:topic:1"); + expect( + getTelegramSequentialKey({ + update: { message: { chat: { id: 555 } } }, + }), + ).toBe("telegram:555"); + }); + + it("routes callback_query payloads as messages and answers callbacks", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const callbackHandler = onSpy.mock.calls.find( + (call) => call[0] === "callback_query", + )?.[1] as (ctx: Record) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-1", + data: "cmd:option_a", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 10, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("cmd:option_a"); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-1"); + }); + + it("wraps inbound message with Telegram envelope", async () => { + const originalTz = process.env.TZ; + process.env.TZ = "Europe/Vienna"; + + try { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + expect(onSpy).toHaveBeenCalledWith("message", expect.any(Function)); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + const message = { + chat: { id: 1234, type: "private" }, + text: "hello world", + date: 1736380800, // 2025-01-09T00:00:00Z + from: { + first_name: "Ada", + last_name: "Lovelace", + username: "ada_bot", + }, + }; + await handler({ + message, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toMatch( + /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T00:00Z\]/, + ); + expect(payload.Body).toContain("hello world"); + } finally { + process.env.TZ = originalTz; + } + }); + + it("requests pairing by default for unknown DM senders", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { telegram: { dmPolicy: "pairing" } }, + }); + readTelegramAllowFromStore.mockResolvedValue([]); + upsertTelegramPairingRequest.mockResolvedValue({ + code: "PAIRME12", + created: true, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + text: "hello", + date: 1736380800, + from: { id: 999, username: "random" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(sendMessageSpy.mock.calls[0]?.[0]).toBe(1234); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain( + "Your Telegram user id: 999", + ); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain( + "Pairing code:", + ); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("PAIRME12"); + }); + + it("does not resend pairing code when a request is already pending", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { telegram: { dmPolicy: "pairing" } }, + }); + readTelegramAllowFromStore.mockResolvedValue([]); + upsertTelegramPairingRequest + .mockResolvedValueOnce({ code: "PAIRME12", created: true }) + .mockResolvedValueOnce({ code: "PAIRME12", created: false }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + const message = { + chat: { id: 1234, type: "private" }, + text: "hello", + date: 1736380800, + from: { id: 999, username: "random" }, + }; + + await handler({ + message, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + await handler({ + message: { ...message, text: "hello again" }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + }); + + it("triggers typing cue via onReplyStart", async () => { + onSpy.mockReset(); + sendChatActionSpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + await handler({ + message: { chat: { id: 42, type: "private" }, text: "hi" }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing", undefined); + }); + + it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + identity: { name: "Bert" }, + messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "bert: introduce yourself", + date: 1736380800, + message_id: 1, + from: { id: 9, first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.WasMentioned).toBe(true); + expect(payload.Body).toMatch( + /^\[Telegram Test Group id:7 from Ada id:9 2025-01-09T00:00Z\]/, + ); + }); + + it("includes sender identity in group envelope headers", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 42, type: "group", title: "Ops" }, + text: "hello", + date: 1736380800, + message_id: 2, + from: { + id: 99, + first_name: "Ada", + last_name: "Lovelace", + username: "ada", + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toMatch( + /^\[Telegram Ops id:42 from Ada Lovelace \(@ada\) id:99 2025-01-09T00:00Z\]/, + ); + }); + + it("reacts to mention-gated group messages when ackReaction is enabled", async () => { + onSpy.mockReset(); + setMessageReactionSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + messages: { + ackReaction: "👀", + ackReactionScope: "group-mentions", + groupChat: { mentionPatterns: ["\\bbert\\b"] }, + }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "bert hello", + date: 1736380800, + message_id: 123, + from: { id: 9, first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(setMessageReactionSpy).toHaveBeenCalledWith(7, 123, [ + { type: "emoji", emoji: "👀" }, + ]); + }); + + it("clears native commands when disabled", () => { + loadConfig.mockReturnValue({ + commands: { native: false }, + }); + + createTelegramBot({ token: "tok" }); + + expect(setMyCommandsSpy).toHaveBeenCalledWith([]); + }); + + it("skips group messages when requireMention is enabled and no mention matches", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "hello everyone", + date: 1736380800, + message_id: 2, + from: { id: 9, first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("allows group messages when requireMention is enabled but mentions cannot be detected", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + messages: { groupChat: { mentionPatterns: [] } }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "hello everyone", + date: 1736380800, + message_id: 3, + from: { id: 9, first_name: "Ada" }, + }, + me: {}, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.WasMentioned).toBe(false); + }); + + it("includes reply-to context when a Telegram reply is received", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "private" }, + text: "Sure, see below", + date: 1736380800, + reply_to_message: { + message_id: 9001, + text: "Can you summarize this?", + from: { first_name: "Ada" }, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("[Replying to Ada id:9001]"); + expect(payload.Body).toContain("Can you summarize this?"); + expect(payload.ReplyToId).toBe("9001"); + expect(payload.ReplyToBody).toBe("Can you summarize this?"); + expect(payload.ReplyToSender).toBe("Ada"); + }); + + it("sends replies without native reply threading", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + replySpy.mockResolvedValue({ text: "a".repeat(4500) }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + await handler({ + message: { + chat: { id: 5, type: "private" }, + text: "hi", + date: 1736380800, + message_id: 101, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); + for (const call of sendMessageSpy.mock.calls) { + expect(call[2]?.reply_to_message_id).toBeUndefined(); + } + }); + + it("honors replyToMode=first for threaded replies", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + replySpy.mockResolvedValue({ + text: "a".repeat(4500), + replyToId: "101", + }); + + createTelegramBot({ token: "tok", replyToMode: "first" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + await handler({ + message: { + chat: { id: 5, type: "private" }, + text: "hi", + date: 1736380800, + message_id: 101, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); + const [first, ...rest] = sendMessageSpy.mock.calls; + expect(first?.[2]?.reply_to_message_id).toBe(101); + for (const call of rest) { + expect(call[2]?.reply_to_message_id).toBeUndefined(); + } + }); + + it("prefixes tool and final replies with responsePrefix", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + replySpy.mockImplementation(async (_ctx, opts) => { + await opts?.onToolResult?.({ text: "tool result" }); + return { text: "final reply" }; + }); + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + messages: { responsePrefix: "PFX" }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + await handler({ + message: { + chat: { id: 5, type: "private" }, + text: "hi", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy).toHaveBeenCalledTimes(2); + expect(sendMessageSpy.mock.calls[0][1]).toBe("PFX tool result"); + expect(sendMessageSpy.mock.calls[1][1]).toBe("PFX final reply"); + }); + + it("honors replyToMode=all for threaded replies", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + replySpy.mockResolvedValue({ + text: "a".repeat(4500), + replyToId: "101", + }); + + createTelegramBot({ token: "tok", replyToMode: "all" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + await handler({ + message: { + chat: { id: 5, type: "private" }, + text: "hi", + date: 1736380800, + message_id: 101, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); + for (const call of sendMessageSpy.mock.calls) { + expect(call[2]?.reply_to_message_id).toBe(101); + } + }); + + it("blocks group messages when telegram.groups is set without a wildcard", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groups: { + "123": { requireMention: false }, + }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 456, type: "group", title: "Ops" }, + text: "@clawdbot_bot hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("skips group messages without mention when requireMention is enabled", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { groups: { "*": { requireMention: true } } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "group", title: "Dev Chat" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("honors routed group activation from session store", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + const storeDir = fs.mkdtempSync( + path.join(os.tmpdir(), "clawdbot-telegram-"), + ); + const storePath = path.join(storeDir, "sessions.json"); + fs.writeFileSync( + storePath, + JSON.stringify({ + "agent:ops:telegram:group:123": { groupActivation: "always" }, + }), + "utf-8", + ); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + bindings: [ + { + agentId: "ops", + match: { + channel: "telegram", + peer: { kind: "group", id: "123" }, + }, + }, + ], + session: { store: storePath }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "group", title: "Routing" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("routes DMs by telegram accountId binding", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + accounts: { + opie: { + botToken: "tok-opie", + dmPolicy: "open", + }, + }, + }, + }, + bindings: [ + { + agentId: "opie", + match: { channel: "telegram", accountId: "opie" }, + }, + ], + }); + + createTelegramBot({ token: "tok", accountId: "opie" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "private" }, + from: { id: 999, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.AccountId).toBe("opie"); + expect(payload.SessionKey).toBe("agent:opie:main"); + }); + + it("allows per-group requireMention override", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { + "*": { requireMention: true }, + "123": { requireMention: false }, + }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "group", title: "Dev Chat" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows per-topic requireMention override", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { + "*": { requireMention: true }, + "-1001234567890": { + requireMention: true, + topics: { + "99": { requireMention: false }, + }, + }, + }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + text: "hello", + date: 1736380800, + message_thread_id: 99, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("honors groups default when no explicit group override exists", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 456, type: "group", title: "Ops" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("does not block group messages when bot username is unknown", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 789, type: "group", title: "No Me" }, + text: "hello", + date: 1736380800, + }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("sends GIF replies as animations", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + replySpy.mockResolvedValueOnce({ + text: "caption", + mediaUrl: "https://example.com/fun", + }); + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("GIF89a"), + contentType: "image/gif", + fileName: "fun.gif", + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + text: "hello world", + date: 1736380800, + message_id: 5, + from: { first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendAnimationSpy).toHaveBeenCalledTimes(1); + expect(sendAnimationSpy).toHaveBeenCalledWith("1234", expect.anything(), { + caption: "caption", + reply_to_message_id: undefined, + }); + expect(sendPhotoSpy).not.toHaveBeenCalled(); + }); + + // groupPolicy tests + it("blocks all group messages when groupPolicy is 'disabled'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "disabled", + allowFrom: ["123456789"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "@clawdbot_bot hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Should NOT call getReplyFromConfig because groupPolicy is disabled + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("blocks group messages from senders not in allowFrom when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], // Does not include sender 999999 + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "notallowed" }, // Not in allowFrom + text: "@clawdbot_bot hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("allows group messages from senders in allowFrom (by ID) when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], + groups: { "*": { requireMention: false } }, // Skip mention check + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, // In allowFrom + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages from senders in allowFrom (by username) when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["@testuser"], // By username + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 12345, username: "testuser" }, // Username matches @testuser + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages from telegram:-prefixed allowFrom entries when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["telegram:77112533"], + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 77112533, username: "mneves" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages from tg:-prefixed allowFrom entries case-insensitively when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["TG:77112533"], + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 77112533, username: "mneves" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows all group messages when groupPolicy is 'open'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "random" }, // Random sender + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("matches usernames case-insensitively when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["@TestUser"], // Uppercase in config + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 12345, username: "testuser" }, // Lowercase in message + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows direct messages regardless of groupPolicy", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "disabled", // Even with disabled, DMs should work + allowFrom: ["123456789"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, // Direct message + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows direct messages with tg/Telegram-prefixed allowFrom entries", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + allowFrom: [" TG:123456789 "], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, // Direct message + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows direct messages with telegram:-prefixed allowFrom entries", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + allowFrom: ["telegram:123456789"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages with wildcard in allowFrom when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["*"], // Wildcard allows everyone + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "random" }, // Random sender, but wildcard allows + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("blocks group messages with no sender ID when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + // No `from` field (e.g., channel post or anonymous admin) + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("matches telegram:-prefixed allowFrom entries in group allowlist", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["telegram:123456789"], // Prefixed format + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, // Matches after stripping prefix + text: "hello from prefixed user", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Should call reply because sender ID matches after stripping telegram: prefix + expect(replySpy).toHaveBeenCalled(); + }); + + it("matches tg:-prefixed allowFrom entries case-insensitively in group allowlist", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["TG:123456789"], // Prefixed format (case-insensitive) + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, // Matches after stripping tg: prefix + text: "hello from prefixed user", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Should call reply because sender ID matches after stripping tg: prefix + expect(replySpy).toHaveBeenCalled(); + }); + + it("blocks group messages when groupPolicy allowlist has no groupAllowFrom", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("allows control commands with TG-prefixed groupAllowFrom entries", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + groupAllowFrom: [" TG:123456789 "], + groups: { "*": { requireMention: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "/status", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("isolates forum topic sessions and carries thread metadata", async () => { + onSpy.mockReset(); + sendChatActionSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 12345, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.SessionKey).toContain( + "telegram:group:-1001234567890:topic:99", + ); + expect(payload.From).toBe("group:-1001234567890:topic:99"); + expect(payload.MessageThreadId).toBe(99); + expect(payload.IsForum).toBe(true); + expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", { + message_thread_id: 99, + }); + }); + + it("falls back to General topic thread id for typing in forums", async () => { + onSpy.mockReset(); + sendChatActionSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 12345, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", { + message_thread_id: 1, + }); + }); + + it("routes General topic replies using thread id 1", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + replySpy.mockResolvedValue({ text: "response" }); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 12345, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy).toHaveBeenCalledWith( + "-1001234567890", + expect.any(String), + expect.objectContaining({ message_thread_id: 1 }), + ); + }); + + it("applies topic skill filters and system prompts", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { + "-1001234567890": { + requireMention: false, + systemPrompt: "Group prompt", + skills: ["group-skill"], + topics: { + "99": { + skills: [], + systemPrompt: "Topic prompt", + }, + }, + }, + }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 12345, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.GroupSystemPrompt).toBe("Group prompt\n\nTopic prompt"); + const opts = replySpy.mock.calls[0][1]; + expect(opts?.skillFilter).toEqual([]); + }); + + it("passes message_thread_id to topic replies", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + commandSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + replySpy.mockResolvedValue({ text: "response" }); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 12345, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy).toHaveBeenCalledWith( + "-1001234567890", + expect.any(String), + expect.objectContaining({ message_thread_id: 99 }), + ); + }); + + it("threads native command replies inside topics", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + commandSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + replySpy.mockResolvedValue({ text: "response" }); + + loadConfig.mockReturnValue({ + commands: { native: true }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + expect(commandSpy).toHaveBeenCalled(); + const handler = commandSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 12345, username: "testuser" }, + text: "/status", + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + match: "", + }); + + expect(sendMessageSpy).toHaveBeenCalledWith( + "-1001234567890", + expect.any(String), + expect.objectContaining({ message_thread_id: 99 }), + ); + }); + + it("streams tool summaries for native slash commands", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + commandSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + replySpy.mockImplementation(async (_ctx, opts) => { + await opts?.onToolResult?.({ text: "tool update" }); + return { text: "final reply" }; + }); + + loadConfig.mockReturnValue({ + commands: { native: true }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }); + + createTelegramBot({ token: "tok" }); + const verboseHandler = commandSpy.mock.calls.find( + (call) => call[0] === "verbose", + )?.[1] as ((ctx: Record) => Promise) | undefined; + if (!verboseHandler) throw new Error("verbose command handler missing"); + + await verboseHandler({ + message: { + chat: { id: 12345, type: "private" }, + from: { id: 12345, username: "testuser" }, + text: "/verbose on", + date: 1736380800, + message_id: 42, + }, + match: "on", + }); + + expect(sendMessageSpy).toHaveBeenCalledTimes(2); + expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("tool update"); + expect(sendMessageSpy.mock.calls[1]?.[1]).toContain("final reply"); + }); + + it("dedupes duplicate message updates by update_id", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + const ctx = { + update: { update_id: 111 }, + message: { + chat: { id: 123, type: "private" }, + from: { id: 456, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }; + + await handler(ctx); + await handler(ctx); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("dedupes duplicate callback_query updates by update_id", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("callback_query") as ( + ctx: Record, + ) => Promise; + + const ctx = { + update: { update_id: 222 }, + callbackQuery: { + id: "cb-1", + data: "ping", + from: { id: 789, username: "testuser" }, + message: { + chat: { id: 123, type: "private" }, + date: 1736380800, + message_id: 9001, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({}), + }; + + await handler(ctx); + await handler(ctx); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows distinct callback_query ids without update_id", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("callback_query") as ( + ctx: Record, + ) => Promise; + + await handler({ + callbackQuery: { + id: "cb-1", + data: "ping", + from: { id: 789, username: "testuser" }, + message: { + chat: { id: 123, type: "private" }, + date: 1736380800, + message_id: 9001, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({}), + }); + + await handler({ + callbackQuery: { + id: "cb-2", + data: "ping", + from: { id: 789, username: "testuser" }, + message: { + chat: { id: 123, type: "private" }, + date: 1736380800, + message_id: 9001, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({}), + }); + + expect(replySpy).toHaveBeenCalledTimes(2); + }); + + it("registers message_reaction handler", () => { + onSpy.mockReset(); + createTelegramBot({ token: "tok" }); + const reactionHandler = onSpy.mock.calls.find( + (call) => call[0] === "message_reaction", + ); + expect(reactionHandler).toBeDefined(); + }); + + it("enqueues system event for reaction on bot message", async () => { + onSpy.mockReset(); + enqueueSystemEvent.mockReset(); + wasSentByBot.mockReturnValue(true); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", reactionNotifications: "own" }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message_reaction") as ( + ctx: Record, + ) => Promise; + + await handler({ + update: { update_id: 500 }, + messageReaction: { + chat: { id: 1234, type: "private" }, + message_id: 42, + user: { id: 9, first_name: "Ada", username: "ada_bot" }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "👍" }], + }, + }); + + expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); + expect(enqueueSystemEvent).toHaveBeenCalledWith( + "Telegram reaction added: 👍 by Ada (@ada_bot) on msg 42", + expect.objectContaining({ + contextKey: expect.stringContaining("telegram:reaction:add:1234:42:9"), + }), + ); + }); + + it("skips reaction when reactionNotifications is off", async () => { + onSpy.mockReset(); + enqueueSystemEvent.mockReset(); + wasSentByBot.mockReturnValue(true); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", reactionNotifications: "off" }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message_reaction") as ( + ctx: Record, + ) => Promise; + + await handler({ + update: { update_id: 501 }, + messageReaction: { + chat: { id: 1234, type: "private" }, + message_id: 42, + user: { id: 9, first_name: "Ada" }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "👍" }], + }, + }); + + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + }); + + it("skips reaction in own mode when message was not sent by bot", async () => { + onSpy.mockReset(); + enqueueSystemEvent.mockReset(); + wasSentByBot.mockReturnValue(false); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", reactionNotifications: "own" }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message_reaction") as ( + ctx: Record, + ) => Promise; + + await handler({ + update: { update_id: 502 }, + messageReaction: { + chat: { id: 1234, type: "private" }, + message_id: 99, + user: { id: 9, first_name: "Ada" }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "👍" }], + }, + }); + + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + }); + + it("allows reaction in all mode regardless of message sender", async () => { + onSpy.mockReset(); + enqueueSystemEvent.mockReset(); + wasSentByBot.mockReturnValue(false); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", reactionNotifications: "all" }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message_reaction") as ( + ctx: Record, + ) => Promise; + + await handler({ + update: { update_id: 503 }, + messageReaction: { + chat: { id: 1234, type: "private" }, + message_id: 99, + user: { id: 9, first_name: "Ada" }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "🎉" }], + }, + }); + + expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); + expect(enqueueSystemEvent).toHaveBeenCalledWith( + "Telegram reaction added: 🎉 by Ada on msg 99", + expect.any(Object), + ); + }); + + it("skips reaction removal (only processes added reactions)", async () => { + onSpy.mockReset(); + enqueueSystemEvent.mockReset(); + wasSentByBot.mockReturnValue(true); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", reactionNotifications: "own" }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message_reaction") as ( + ctx: Record, + ) => Promise; + + await handler({ + update: { update_id: 504 }, + messageReaction: { + chat: { id: 1234, type: "private" }, + message_id: 42, + user: { id: 9, first_name: "Ada" }, + date: 1736380800, + old_reaction: [{ type: "emoji", emoji: "👍" }], + new_reaction: [], + }, + }); + + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 31a345fcb..21c2c8d41 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -17,8 +17,16 @@ import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, } from "../config/group-policy.js"; -import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; -import { logVerbose, shouldLogVerbose } from "../globals.js"; +import { + loadSessionStore, + resolveStorePath, + updateLastRoute, +} from "../config/sessions.js"; +import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; +import { recordChannelActivity } from "../infra/channel-activity.js"; +import { createDedupeCache } from "../infra/dedupe.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { enqueueSystemEvent } from "../infra/system-events.js"; import { getChildLogger } from "../logging.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveTelegramAccount } from "./accounts.js"; @@ -34,6 +42,12 @@ import { type TelegramUpdateKeyContext, } from "./bot-updates.js"; import { resolveTelegramFetch } from "./fetch.js"; +import { + readTelegramAllowFromStore, + upsertTelegramPairingRequest, +} from "./pairing-store.js"; +import { wasSentByBot } from "./sent-message-cache.js"; +import { resolveTelegramVoiceSend } from "./voice.js"; export type TelegramBotOptions = { token: string; @@ -59,8 +73,14 @@ export function getTelegramSequentialKey(ctx: { message?: TelegramMessage; edited_message?: TelegramMessage; callback_query?: { message?: TelegramMessage }; + message_reaction?: { chat?: { id?: number } }; }; }): string { + // Handle reaction updates + const reaction = ctx.update?.message_reaction; + if (reaction?.chat?.id) { + return `telegram:${reaction.chat.id}`; + } const msg = ctx.message ?? ctx.update?.message ?? @@ -291,6 +311,86 @@ export function createTelegramBot(opts: TelegramBotOptions) { opts, }); + // Handle emoji reactions to messages + bot.on("message_reaction", async (ctx) => { + try { + const reaction = ctx.messageReaction; + if (!reaction) return; + if (shouldSkipUpdate(ctx)) return; + + const chatId = reaction.chat.id; + const messageId = reaction.message_id; + const user = reaction.user; + + // Resolve reaction notification mode (default: "own") + const reactionMode = telegramCfg.reactionNotifications ?? "own"; + if (reactionMode === "off") return; + + // For "own" mode, only notify for reactions to bot's messages + if (reactionMode === "own" && !wasSentByBot(chatId, messageId)) { + return; + } + + // Detect added reactions + const oldEmojis = new Set( + reaction.old_reaction + .filter( + (r): r is { type: "emoji"; emoji: string } => r.type === "emoji", + ) + .map((r) => r.emoji), + ); + const addedReactions = reaction.new_reaction + .filter( + (r): r is { type: "emoji"; emoji: string } => r.type === "emoji", + ) + .filter((r) => !oldEmojis.has(r.emoji)); + + if (addedReactions.length === 0) return; + + // Build sender label + const senderName = user + ? [user.first_name, user.last_name].filter(Boolean).join(" ").trim() || + user.username + : undefined; + const senderUsername = user?.username ? `@${user.username}` : undefined; + let senderLabel = senderName; + if (senderName && senderUsername) { + senderLabel = `${senderName} (${senderUsername})`; + } else if (!senderName && senderUsername) { + senderLabel = senderUsername; + } + if (!senderLabel && user?.id) { + senderLabel = `id:${user.id}`; + } + senderLabel = senderLabel || "unknown"; + + // Resolve agent route for session + const isGroup = + reaction.chat.type === "group" || reaction.chat.type === "supergroup"; + const route = resolveAgentRoute({ + cfg, + channel: "telegram", + accountId: account.accountId, + peer: { kind: isGroup ? "group" : "dm", id: String(chatId) }, + }); + + // Enqueue system event for each added reaction + for (const r of addedReactions) { + const emoji = r.emoji; + const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`; + enqueueSystemEvent(text, { + sessionKey: route.sessionKey, + contextKey: `telegram:reaction:add:${chatId}:${messageId}:${user?.id ?? "anon"}:${emoji}`, + }); + logVerbose(`telegram: reaction event enqueued: ${text}`); + } + } catch (err) { + runtime.error?.( + danger(`telegram reaction handler failed: ${String(err)}`), + ); + } + }); + registerTelegramHandlers({ cfg, accountId: account.accountId, diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 0d6c3fca5..804dbc50e 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -17,7 +17,11 @@ import { loadWebMedia } from "../web/media.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramFetch } from "./fetch.js"; import { markdownToTelegramHtml } from "./format.js"; -import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js"; +import { recordSentMessage } from "./sent-message-cache.js"; +import { + parseTelegramTarget, + stripTelegramInternalPrefixes, +} from "./targets.js"; import { resolveTelegramVoiceSend } from "./voice.js"; type TelegramSendOpts = { @@ -272,6 +276,9 @@ export async function sendMessageTelegram( } const mediaMessageId = String(result?.message_id ?? "unknown"); const resolvedChatId = String(result?.chat?.id ?? chatId); + if (result?.message_id) { + recordSentMessage(chatId, result.message_id); + } recordChannelActivity({ channel: "telegram", accountId: account.accountId, @@ -353,6 +360,9 @@ export async function sendMessageTelegram( }, ); const messageId = String(res?.message_id ?? "unknown"); + if (res?.message_id) { + recordSentMessage(chatId, res.message_id); + } recordChannelActivity({ channel: "telegram", accountId: account.accountId, diff --git a/src/telegram/sent-message-cache.test.ts b/src/telegram/sent-message-cache.test.ts new file mode 100644 index 000000000..95f778229 --- /dev/null +++ b/src/telegram/sent-message-cache.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + clearSentMessageCache, + recordSentMessage, + wasSentByBot, +} from "./sent-message-cache.js"; + +describe("sent-message-cache", () => { + afterEach(() => { + clearSentMessageCache(); + }); + + it("records and retrieves sent messages", () => { + recordSentMessage(123, 1); + recordSentMessage(123, 2); + recordSentMessage(456, 10); + + expect(wasSentByBot(123, 1)).toBe(true); + expect(wasSentByBot(123, 2)).toBe(true); + expect(wasSentByBot(456, 10)).toBe(true); + expect(wasSentByBot(123, 3)).toBe(false); + expect(wasSentByBot(789, 1)).toBe(false); + }); + + it("handles string chat IDs", () => { + recordSentMessage("123", 1); + expect(wasSentByBot("123", 1)).toBe(true); + expect(wasSentByBot(123, 1)).toBe(true); + }); + + it("clears cache", () => { + recordSentMessage(123, 1); + expect(wasSentByBot(123, 1)).toBe(true); + + clearSentMessageCache(); + expect(wasSentByBot(123, 1)).toBe(false); + }); +}); diff --git a/src/telegram/sent-message-cache.ts b/src/telegram/sent-message-cache.ts new file mode 100644 index 000000000..5951d4061 --- /dev/null +++ b/src/telegram/sent-message-cache.ts @@ -0,0 +1,70 @@ +/** + * In-memory cache of sent message IDs per chat. + * Used to identify bot's own messages for reaction filtering ("own" mode). + */ + +const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +type CacheEntry = { + messageIds: Set; + timestamps: Map; +}; + +const sentMessages = new Map(); + +function getChatKey(chatId: number | string): string { + return String(chatId); +} + +function cleanupExpired(entry: CacheEntry): void { + const now = Date.now(); + for (const [msgId, timestamp] of entry.timestamps) { + if (now - timestamp > TTL_MS) { + entry.messageIds.delete(msgId); + entry.timestamps.delete(msgId); + } + } +} + +/** + * Record a message ID as sent by the bot. + */ +export function recordSentMessage( + chatId: number | string, + messageId: number, +): void { + const key = getChatKey(chatId); + let entry = sentMessages.get(key); + if (!entry) { + entry = { messageIds: new Set(), timestamps: new Map() }; + sentMessages.set(key, entry); + } + entry.messageIds.add(messageId); + entry.timestamps.set(messageId, Date.now()); + // Periodic cleanup + if (entry.messageIds.size > 100) { + cleanupExpired(entry); + } +} + +/** + * Check if a message was sent by the bot. + */ +export function wasSentByBot( + chatId: number | string, + messageId: number, +): boolean { + const key = getChatKey(chatId); + const entry = sentMessages.get(key); + if (!entry) return false; + // Clean up expired entries on read + cleanupExpired(entry); + return entry.messageIds.has(messageId); +} + +/** + * Clear all cached entries (for testing). + */ +export function clearSentMessageCache(): void { + sentMessages.clear(); +}