From d05c3d0659f46c708d744aaa58ae39233c619794 Mon Sep 17 00:00:00 2001 From: Bohdan Podvirnyi Date: Tue, 13 Jan 2026 21:13:05 +0200 Subject: [PATCH 1/6] 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(); +} From 0e1dcf9cb4f55ea66d58f0f7dc0dc2149f47cd7b Mon Sep 17 00:00:00 2001 From: Bohdan Podvirnyi Date: Tue, 13 Jan 2026 21:34:40 +0200 Subject: [PATCH 2/6] feat: added capability for clawdbot to react --- docs/channels/telegram.md | 44 ++++++ src/agents/system-prompt.ts | 28 ++++ src/agents/tools/telegram-actions.test.ts | 73 +++++++++- src/agents/tools/telegram-actions.ts | 15 ++- src/config/types.ts | 6 +- src/config/zod-schema.ts | 3 +- src/telegram/bot.test.ts | 155 +++++++++++++++++----- src/telegram/bot.ts | 29 ++-- src/telegram/monitor.ts | 20 +++ src/telegram/reaction-level.test.ts | 117 ++++++++++++++++ src/telegram/reaction-level.ts | 65 +++++++++ src/telegram/webhook.ts | 4 + 12 files changed, 503 insertions(+), 56 deletions(-) create mode 100644 src/telegram/reaction-level.test.ts create mode 100644 src/telegram/reaction-level.ts diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 7cd6c45ea..74e4a24f0 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -297,6 +297,48 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti - Reaction removal semantics: see [/tools/reactions](/tools/reactions). - Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled). +## Reaction notifications + +**How reactions work:** +Telegram reactions arrive as **separate `message_reaction` events**, not as properties in message payloads. When a user adds a reaction, Clawdbot: + +1. Receives the `message_reaction` update from Telegram API +2. Converts it to a **system event** with format: `"Telegram reaction added: {emoji} by {user} on msg {id}"` +3. Enqueues the system event using the **same session key** as regular messages +4. When the next message arrives in that conversation, system events are drained and prepended to the agent's context + +The agent sees reactions as **system notifications** in the conversation history, not as message metadata. + +**Configuration:** +- `channels.telegram.reactionNotifications`: Controls which reactions trigger notifications + - `"off"` — ignore all reactions (default when not set) + - `"all"` — notify for all reactions + +- `channels.telegram.reactionLevel`: Controls agent's reaction capability + - `"off"` — agent cannot react to messages + - `"ack"` — bot sends acknowledgment reactions (👀 while processing) + - `"minimal"` — agent can react sparingly (guideline: 1 per 5-10 exchanges) + - `"extensive"` — agent can react liberally when appropriate + +**Forum groups:** Reactions in forum groups include `message_thread_id` and use session keys like `agent:main:telegram:group:{chatId}:topic:{threadId}`. This ensures reactions and messages in the same topic stay together. + +**Example config:** +```json5 +{ + channels: { + telegram: { + reactionNotifications: "all", // See all reactions + reactionLevel: "minimal" // Agent can react sparingly + } + } +} +``` + +**Requirements:** +- Telegram bots must explicitly request `message_reaction` in `allowed_updates` (configured automatically by Clawdbot) +- For webhook mode, reactions are included in the webhook `allowed_updates` +- For polling mode, reactions are included in the `getUpdates` `allowed_updates` + ## Delivery targets (CLI/cron) - Use a chat id (`123456789`) or a username (`@name`) as the target. - Example: `clawdbot message send --channel telegram --to 123456789 --message "hi"`. @@ -360,6 +402,8 @@ Provider options: - `channels.telegram.actions.reactions`: gate Telegram tool reactions. - `channels.telegram.actions.sendMessage`: gate Telegram tool message sends. - `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes. +- `channels.telegram.reactionNotifications`: `off | all` — control which reactions trigger system events (default: `off` when not set). +- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `off` when not set). Related global options: - `agents.list[].groupChat.mentionPatterns` (mention gating patterns). diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 70563cd66..650620410 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -43,6 +43,11 @@ export function buildAgentSystemPrompt(params: { defaultLevel: "on" | "off"; }; }; + /** Reaction guidance for the agent (for Telegram minimal/extensive modes). */ + reactionGuidance?: { + level: "minimal" | "extensive"; + channel: string; + }; }) { const coreToolSummaries: Record = { read: "Read file contents", @@ -351,6 +356,29 @@ export function buildAgentSystemPrompt(params: { if (extraSystemPrompt) { lines.push("## Group Chat Context", extraSystemPrompt, ""); } + if (params.reactionGuidance) { + const { level, channel } = params.reactionGuidance; + const guidanceText = + level === "minimal" + ? [ + `Reactions are enabled for ${channel} in MINIMAL mode.`, + "React ONLY when truly relevant:", + "- Acknowledge important user requests or confirmations", + "- Express genuine sentiment (humor, appreciation) sparingly", + "- Avoid reacting to routine messages or your own replies", + "Guideline: at most 1 reaction per 5-10 exchanges.", + ].join("\n") + : [ + `Reactions are enabled for ${channel} in EXTENSIVE mode.`, + "Feel free to react liberally:", + "- Acknowledge messages with appropriate emojis", + "- Express sentiment and personality through reactions", + "- React to interesting content, humor, or notable events", + "- Use reactions to confirm understanding or agreement", + "Guideline: react whenever it feels natural.", + ].join("\n"); + lines.push("## Reactions", guidanceText, ""); + } if (reasoningHint) { lines.push("## Reasoning Format", reasoningHint, ""); } diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index 134759464..8f4bb2a9b 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -33,9 +33,30 @@ describe("handleTelegramAction", () => { } }); - it("adds reactions", async () => { + it("adds reactions when reactionLevel is minimal", async () => { const cfg = { - channels: { telegram: { botToken: "tok" } }, + channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } }, + } as ClawdbotConfig; + await handleTelegramAction( + { + action: "react", + chatId: "123", + messageId: "456", + emoji: "✅", + }, + cfg, + ); + expect(reactMessageTelegram).toHaveBeenCalledWith( + "123", + 456, + "✅", + expect.objectContaining({ token: "tok", remove: false }), + ); + }); + + it("adds reactions when reactionLevel is extensive", async () => { + const cfg = { + channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } }, } as ClawdbotConfig; await handleTelegramAction( { @@ -56,7 +77,7 @@ describe("handleTelegramAction", () => { it("removes reactions on empty emoji", async () => { const cfg = { - channels: { telegram: { botToken: "tok" } }, + channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } }, } as ClawdbotConfig; await handleTelegramAction( { @@ -77,7 +98,7 @@ describe("handleTelegramAction", () => { it("removes reactions when remove flag set", async () => { const cfg = { - channels: { telegram: { botToken: "tok" } }, + channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } }, } as ClawdbotConfig; await handleTelegramAction( { @@ -97,10 +118,48 @@ describe("handleTelegramAction", () => { ); }); - it("respects reaction gating", async () => { + it("blocks reactions when reactionLevel is off", async () => { + const cfg = { + channels: { telegram: { botToken: "tok", reactionLevel: "off" } }, + } as ClawdbotConfig; + await expect( + handleTelegramAction( + { + action: "react", + chatId: "123", + messageId: "456", + emoji: "✅", + }, + cfg, + ), + ).rejects.toThrow(/Telegram agent reactions disabled.*reactionLevel="off"/); + }); + + it("blocks reactions when reactionLevel is ack (default)", async () => { + const cfg = { + channels: { telegram: { botToken: "tok", reactionLevel: "ack" } }, + } as ClawdbotConfig; + await expect( + handleTelegramAction( + { + action: "react", + chatId: "123", + messageId: "456", + emoji: "✅", + }, + cfg, + ), + ).rejects.toThrow(/Telegram agent reactions disabled.*reactionLevel="ack"/); + }); + + it("also respects legacy actions.reactions gating", async () => { const cfg = { channels: { - telegram: { botToken: "tok", actions: { reactions: false } }, + telegram: { + botToken: "tok", + reactionLevel: "minimal", + actions: { reactions: false }, + }, }, } as ClawdbotConfig; await expect( @@ -113,7 +172,7 @@ describe("handleTelegramAction", () => { }, cfg, ), - ).rejects.toThrow(/Telegram reactions are disabled/); + ).rejects.toThrow(/Telegram reactions are disabled via actions.reactions/); }); it("sends a text message", async () => { diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index c340954aa..5ffc52515 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -1,6 +1,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js"; import { deleteMessageTelegram, reactMessageTelegram, @@ -82,8 +83,20 @@ export async function handleTelegramAction( const isActionEnabled = createActionGate(cfg.channels?.telegram?.actions); if (action === "react") { + // Check reaction level first + const reactionLevelInfo = resolveTelegramReactionLevel({ + cfg, + accountId: accountId ?? undefined, + }); + if (!reactionLevelInfo.agentReactionsEnabled) { + throw new Error( + `Telegram agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` + + `Set channels.telegram.reactionLevel to "minimal" or "extensive" to enable.`, + ); + } + // Also check the existing action gate for backward compatibility if (!isActionEnabled("reactions")) { - throw new Error("Telegram reactions are disabled."); + throw new Error("Telegram reactions are disabled via actions.reactions."); } const chatId = readStringOrNumberParam(params, "chatId", { required: true, diff --git a/src/config/types.ts b/src/config/types.ts index bbc165b8a..3ded5f7e2 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -423,8 +423,10 @@ export type TelegramAccountConfig = { 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"; + /** Reaction notification mode: off (default), all. */ + reactionNotifications?: "off" | "all"; + /** Reaction level: off, ack (default), minimal, extensive. */ + reactionLevel?: "off" | "ack" | "minimal" | "extensive"; mediaMaxMb?: number; /** Retry policy for outbound Telegram API calls. */ retry?: OutboundRetryConfig; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 0e61fc460..0ac31345c 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -297,7 +297,8 @@ const TelegramAccountSchemaBase = z.object({ draftChunk: BlockStreamingChunkSchema.optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"), - reactionNotifications: z.enum(["off", "own", "all"]).optional(), + reactionNotifications: z.enum(["off", "all"]).optional(), + reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), mediaMaxMb: z.number().positive().optional(), retry: RetryConfigSchema, proxy: z.string().optional(), diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 0a8882fa2..3aa8af1ac 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -2244,14 +2244,13 @@ describe("createTelegramBot", () => { expect(reactionHandler).toBeDefined(); }); - it("enqueues system event for reaction on bot message", async () => { + it("enqueues system event for reaction", async () => { onSpy.mockReset(); enqueueSystemEvent.mockReset(); - wasSentByBot.mockReturnValue(true); loadConfig.mockReturnValue({ channels: { - telegram: { dmPolicy: "open", reactionNotifications: "own" }, + telegram: { dmPolicy: "open", reactionNotifications: "all" }, }, }); @@ -2312,37 +2311,6 @@ describe("createTelegramBot", () => { 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(); @@ -2381,11 +2349,10 @@ describe("createTelegramBot", () => { it("skips reaction removal (only processes added reactions)", async () => { onSpy.mockReset(); enqueueSystemEvent.mockReset(); - wasSentByBot.mockReturnValue(true); loadConfig.mockReturnValue({ channels: { - telegram: { dmPolicy: "open", reactionNotifications: "own" }, + telegram: { dmPolicy: "open", reactionNotifications: "all" }, }, }); @@ -2408,4 +2375,120 @@ describe("createTelegramBot", () => { expect(enqueueSystemEvent).not.toHaveBeenCalled(); }); + + it("uses correct session key for forum group reactions with topic", async () => { + onSpy.mockReset(); + enqueueSystemEvent.mockReset(); + + 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: 505 }, + messageReaction: { + chat: { id: 5678, type: "supergroup", is_forum: true }, + message_id: 100, + message_thread_id: 42, + user: { id: 10, first_name: "Bob", username: "bob_user" }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "🔥" }], + }, + }); + + expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); + expect(enqueueSystemEvent).toHaveBeenCalledWith( + "Telegram reaction added: 🔥 by Bob (@bob_user) on msg 100", + expect.objectContaining({ + sessionKey: expect.stringContaining("telegram:group:5678:topic:42"), + contextKey: expect.stringContaining("telegram:reaction:add:5678:100:10"), + }), + ); + }); + + it("uses correct session key for forum group reactions in general topic", async () => { + onSpy.mockReset(); + enqueueSystemEvent.mockReset(); + + 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: 506 }, + messageReaction: { + chat: { id: 5678, type: "supergroup", is_forum: true }, + message_id: 101, + // No message_thread_id - should default to general topic (1) + user: { id: 10, first_name: "Bob" }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "👀" }], + }, + }); + + expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); + expect(enqueueSystemEvent).toHaveBeenCalledWith( + "Telegram reaction added: 👀 by Bob on msg 101", + expect.objectContaining({ + sessionKey: expect.stringContaining("telegram:group:5678:topic:1"), + contextKey: expect.stringContaining("telegram:reaction:add:5678:101:10"), + }), + ); + }); + + it("uses correct session key for regular group reactions without topic", async () => { + onSpy.mockReset(); + enqueueSystemEvent.mockReset(); + + 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: 507 }, + messageReaction: { + chat: { id: 9999, type: "group" }, + message_id: 200, + user: { id: 11, first_name: "Charlie" }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "❤️" }], + }, + }); + + expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); + expect(enqueueSystemEvent).toHaveBeenCalledWith( + "Telegram reaction added: ❤️ by Charlie on msg 200", + expect.objectContaining({ + sessionKey: expect.stringContaining("telegram:group:9999"), + contextKey: expect.stringContaining("telegram:reaction:add:9999:200:11"), + }), + ); + // Verify session key does NOT contain :topic: + const sessionKey = enqueueSystemEvent.mock.calls[0][1].sessionKey; + expect(sessionKey).not.toContain(":topic:"); + }); }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 21c2c8d41..2a02b7503 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -28,9 +28,14 @@ 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 { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveTelegramAccount } from "./accounts.js"; -import { resolveTelegramForumThreadId, resolveTelegramStreamMode } from "./bot/helpers.js"; +import { + buildTelegramGroupPeerId, + resolveTelegramForumThreadId, + resolveTelegramStreamMode, +} from "./bot/helpers.js"; import type { TelegramContext, TelegramMessage } from "./bot/types.js"; import { registerTelegramHandlers } from "./bot-handlers.js"; import { createTelegramMessageProcessor } from "./bot-message.js"; @@ -322,15 +327,10 @@ export function createTelegramBot(opts: TelegramBotOptions) { const messageId = reaction.message_id; const user = reaction.user; - // Resolve reaction notification mode (default: "own") - const reactionMode = telegramCfg.reactionNotifications ?? "own"; + // Resolve reaction notification mode (default: "off") + const reactionMode = telegramCfg.reactionNotifications ?? "off"; 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 @@ -364,14 +364,25 @@ export function createTelegramBot(opts: TelegramBotOptions) { } senderLabel = senderLabel || "unknown"; + // Extract forum thread info (similar to message processing) + const messageThreadId = (reaction as any).message_thread_id; + const isForum = (reaction.chat as any).is_forum === true; + const resolvedThreadId = resolveTelegramForumThreadId({ + isForum, + messageThreadId, + }); + // Resolve agent route for session const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup"; + const peerId = isGroup + ? buildTelegramGroupPeerId(chatId, resolvedThreadId) + : String(chatId); const route = resolveAgentRoute({ cfg, channel: "telegram", accountId: account.accountId, - peer: { kind: isGroup ? "group" : "dm", id: String(chatId) }, + peer: { kind: isGroup ? "group" : "dm", id: peerId }, }); // Enqueue system event for each added reaction diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index f84f52fc7..d0417669b 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -33,6 +33,11 @@ export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions { + const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; + + beforeAll(() => { + process.env.TELEGRAM_BOT_TOKEN = "test-token"; + }); + + afterAll(() => { + if (prevTelegramToken === undefined) { + delete process.env.TELEGRAM_BOT_TOKEN; + } else { + process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken; + } + }); + + it("defaults to ack level when reactionLevel is not set", () => { + const cfg: ClawdbotConfig = { + channels: { telegram: {} }, + }; + + const result = resolveTelegramReactionLevel({ cfg }); + expect(result.level).toBe("ack"); + expect(result.ackEnabled).toBe(true); + expect(result.agentReactionsEnabled).toBe(false); + expect(result.agentReactionGuidance).toBeUndefined(); + }); + + it("returns off level with no reactions enabled", () => { + const cfg: ClawdbotConfig = { + channels: { telegram: { reactionLevel: "off" } }, + }; + + const result = resolveTelegramReactionLevel({ cfg }); + expect(result.level).toBe("off"); + expect(result.ackEnabled).toBe(false); + expect(result.agentReactionsEnabled).toBe(false); + expect(result.agentReactionGuidance).toBeUndefined(); + }); + + it("returns ack level with only ackEnabled", () => { + const cfg: ClawdbotConfig = { + channels: { telegram: { reactionLevel: "ack" } }, + }; + + const result = resolveTelegramReactionLevel({ cfg }); + expect(result.level).toBe("ack"); + expect(result.ackEnabled).toBe(true); + expect(result.agentReactionsEnabled).toBe(false); + expect(result.agentReactionGuidance).toBeUndefined(); + }); + + it("returns minimal level with agent reactions enabled and minimal guidance", () => { + const cfg: ClawdbotConfig = { + channels: { telegram: { reactionLevel: "minimal" } }, + }; + + const result = resolveTelegramReactionLevel({ cfg }); + expect(result.level).toBe("minimal"); + expect(result.ackEnabled).toBe(false); + expect(result.agentReactionsEnabled).toBe(true); + expect(result.agentReactionGuidance).toBe("minimal"); + }); + + it("returns extensive level with agent reactions enabled and extensive guidance", () => { + const cfg: ClawdbotConfig = { + channels: { telegram: { reactionLevel: "extensive" } }, + }; + + const result = resolveTelegramReactionLevel({ cfg }); + expect(result.level).toBe("extensive"); + expect(result.ackEnabled).toBe(false); + expect(result.agentReactionsEnabled).toBe(true); + expect(result.agentReactionGuidance).toBe("extensive"); + }); + + it("resolves reaction level from a specific account", () => { + const cfg: ClawdbotConfig = { + channels: { + telegram: { + reactionLevel: "ack", + accounts: { + work: { botToken: "tok-work", reactionLevel: "extensive" }, + }, + }, + }, + }; + + const result = resolveTelegramReactionLevel({ cfg, accountId: "work" }); + expect(result.level).toBe("extensive"); + expect(result.ackEnabled).toBe(false); + expect(result.agentReactionsEnabled).toBe(true); + expect(result.agentReactionGuidance).toBe("extensive"); + }); + + it("falls back to global level when account has no reactionLevel", () => { + const cfg: ClawdbotConfig = { + channels: { + telegram: { + reactionLevel: "minimal", + accounts: { + work: { botToken: "tok-work" }, + }, + }, + }, + }; + + const result = resolveTelegramReactionLevel({ cfg, accountId: "work" }); + expect(result.level).toBe("minimal"); + expect(result.agentReactionsEnabled).toBe(true); + expect(result.agentReactionGuidance).toBe("minimal"); + }); +}); diff --git a/src/telegram/reaction-level.ts b/src/telegram/reaction-level.ts new file mode 100644 index 000000000..050e13990 --- /dev/null +++ b/src/telegram/reaction-level.ts @@ -0,0 +1,65 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveTelegramAccount } from "./accounts.js"; + +export type TelegramReactionLevel = "off" | "ack" | "minimal" | "extensive"; + +export type ResolvedReactionLevel = { + level: TelegramReactionLevel; + /** Whether ACK reactions (e.g., 👀 when processing) are enabled. */ + ackEnabled: boolean; + /** Whether agent-controlled reactions are enabled. */ + agentReactionsEnabled: boolean; + /** Guidance level for agent reactions (minimal = sparse, extensive = liberal). */ + agentReactionGuidance?: "minimal" | "extensive"; +}; + +/** + * Resolve the effective reaction level and its implications. + */ +export function resolveTelegramReactionLevel(params: { + cfg: ClawdbotConfig; + accountId?: string; +}): ResolvedReactionLevel { + const account = resolveTelegramAccount({ + cfg: params.cfg, + accountId: params.accountId, + }); + const level = (account.config.reactionLevel ?? + "ack") as TelegramReactionLevel; + + switch (level) { + case "off": + return { + level, + ackEnabled: false, + agentReactionsEnabled: false, + }; + case "ack": + return { + level, + ackEnabled: true, + agentReactionsEnabled: false, + }; + case "minimal": + return { + level, + ackEnabled: false, + agentReactionsEnabled: true, + agentReactionGuidance: "minimal", + }; + case "extensive": + return { + level, + ackEnabled: false, + agentReactionsEnabled: true, + agentReactionGuidance: "extensive", + }; + default: + // Fallback to ack behavior + return { + level: "ack", + ackEnabled: true, + agentReactionsEnabled: false, + }; + } +} diff --git a/src/telegram/webhook.ts b/src/telegram/webhook.ts index 6fa0342d5..363aecec0 100644 --- a/src/telegram/webhook.ts +++ b/src/telegram/webhook.ts @@ -63,6 +63,10 @@ export async function startTelegramWebhook(opts: { await bot.api.setWebhook(publicUrl, { secret_token: opts.secret, + allowed_updates: [ + "message", + "message_reaction", + ], }); await new Promise((resolve) => server.listen(port, host, resolve)); From eb7656d68c3ceb8f84dc37b042ab1ff093884d6c Mon Sep 17 00:00:00 2001 From: Bohdan Podvirnyi Date: Thu, 15 Jan 2026 18:13:49 +0200 Subject: [PATCH 3/6] fix: lint errors --- src/config/types.ts | 27 +- src/config/zod-schema.ts | 224 ++++-------- src/telegram/bot.test.ts | 452 +++++++----------------- src/telegram/bot.ts | 37 +- src/telegram/monitor.ts | 5 +- src/telegram/reaction-level.ts | 3 +- src/telegram/send.ts | 5 +- src/telegram/sent-message-cache.test.ts | 6 +- src/telegram/sent-message-cache.ts | 10 +- src/telegram/webhook.ts | 5 +- 10 files changed, 202 insertions(+), 572 deletions(-) diff --git a/src/config/types.ts b/src/config/types.ts index 3ded5f7e2..fb0edc1ad 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -91,14 +91,7 @@ export type SessionConfig = { export type LoggingConfig = { level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace"; file?: string; - consoleLevel?: - | "silent" - | "fatal" - | "error" - | "warn" - | "info" - | "debug" - | "trace"; + consoleLevel?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace"; consoleStyle?: "pretty" | "compact" | "json"; /** Redact sensitive tokens in tool summaries. Default: "tools". */ redactSensitive?: "off" | "tools"; @@ -122,9 +115,7 @@ export type WebConfig = { }; // Provider docking: allowlists keyed by provider id (and internal "webchat"). -export type AgentElevatedAllowFromConfig = Partial< - Record> ->; +export type AgentElevatedAllowFromConfig = Partial>>; export type IdentityConfig = { name?: string; @@ -495,11 +486,7 @@ export type DiscordGuildChannelConfig = { systemPrompt?: string; }; -export type DiscordReactionNotificationMode = - | "off" - | "own" - | "all" - | "allowlist"; +export type DiscordReactionNotificationMode = "off" | "own" | "all" | "allowlist"; export type DiscordGuildEntry = { slug?: string; @@ -614,11 +601,7 @@ export type SlackChannelConfig = { systemPrompt?: string; }; -export type SignalReactionNotificationMode = - | "off" - | "own" - | "all" - | "allowlist"; +export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist"; export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist"; @@ -1863,4 +1846,4 @@ export type ConfigFileSnapshot = { 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 0ac31345c..ed6140ace 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -87,16 +87,8 @@ const QueueModeSchema = z.union([ 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"), -]); +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: @@ -116,18 +108,12 @@ 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"), - ]) + .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(), + 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(), }); @@ -135,12 +121,8 @@ const HumanDelaySchema = z.object({ 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(), + 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(), @@ -150,14 +132,10 @@ const CliBackendSchema = z.object({ 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(), + 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(), + systemPromptMode: z.union([z.literal("append"), z.literal("replace")]).optional(), systemPromptWhen: z .union([z.literal("first"), z.literal("always"), z.literal("never")]) .optional(), @@ -236,9 +214,7 @@ const TranscribeAudioSchema = z }) .optional(); -const HexColorSchema = z - .string() - .regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)"); +const HexColorSchema = z.string().regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)"); const ExecutableTokenSchema = z .string() @@ -312,18 +288,16 @@ const TelegramAccountSchemaBase = z.object({ .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 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(), @@ -372,9 +346,7 @@ const DiscordGuildSchema = z.object({ 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(), + channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(), }); const DiscordAccountSchema = z.object({ @@ -527,18 +499,15 @@ const SignalAccountSchemaBase = z.object({ 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 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(), @@ -548,8 +517,7 @@ const SignalConfigSchema = SignalAccountSchemaBase.extend({ allowFrom: value.allowFrom, ctx, path: ["allowFrom"], - message: - 'channels.signal.dmPolicy="open" requires channels.signal.allowFrom to include "*"', + message: 'channels.signal.dmPolicy="open" requires channels.signal.allowFrom to include "*"', }); }); @@ -559,9 +527,7 @@ const IMessageAccountSchemaBase = z.object({ enabled: z.boolean().optional(), cliPath: ExecutableTokenSchema.optional(), dbPath: z.string().optional(), - service: z - .union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]) - .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(), @@ -587,18 +553,16 @@ const IMessageAccountSchemaBase = z.object({ .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 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(), @@ -696,24 +660,18 @@ const WhatsAppAccountSchema = z .object({ emoji: z.string().optional(), direct: z.boolean().optional().default(true), - group: z - .enum(["always", "mentions", "never"]) - .optional() - .default("mentions"), + 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); + 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 "*"', + message: 'channels.whatsapp.accounts.*.dmPolicy="open" requires allowFrom to include "*"', }); }); @@ -755,18 +713,13 @@ const WhatsAppConfigSchema = z .object({ emoji: z.string().optional(), direct: z.boolean().optional().default(true), - group: z - .enum(["always", "mentions", "never"]) - .optional() - .default("mentions"), + 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); + const allow = (value.allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean); if (allow.includes("*")) return; ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -816,11 +769,7 @@ const SessionSchema = z .object({ channel: z.string().optional(), chatType: z - .union([ - z.literal("direct"), - z.literal("group"), - z.literal("room"), - ]) + .union([z.literal("direct"), z.literal("group"), z.literal("room")]) .optional(), keyPrefix: z.string().optional(), }) @@ -845,9 +794,7 @@ const MessagesSchema = z groupChat: GroupChatSchema, queue: QueueSchema, ackReaction: z.string().optional(), - ackReactionScope: z - .enum(["group-mentions", "group-all", "direct", "all"]) - .optional(), + ackReactionScope: z.enum(["group-mentions", "group-all", "direct", "all"]).optional(), removeAckAfterReply: z.boolean().optional(), }) .optional(); @@ -972,12 +919,7 @@ const ToolPolicySchema = z .optional(); const ToolProfileSchema = z - .union([ - z.literal("minimal"), - z.literal("coding"), - z.literal("messaging"), - z.literal("full"), - ]) + .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). @@ -987,18 +929,10 @@ const ElevatedAllowFromSchema = z 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(), + 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, @@ -1181,11 +1115,7 @@ const BindingsSchema = z accountId: z.string().optional(), peer: z .object({ - kind: z.union([ - z.literal("dm"), - z.literal("group"), - z.literal("channel"), - ]), + kind: z.union([z.literal("dm"), z.literal("group"), z.literal("channel")]), id: z.string(), }) .optional(), @@ -1221,9 +1151,7 @@ const HookMappingSchema = z }) .optional(), action: z.union([z.literal("wake"), z.literal("agent")]).optional(), - wakeMode: z - .union([z.literal("now"), z.literal("next-heartbeat")]) - .optional(), + wakeMode: z.union([z.literal("now"), z.literal("next-heartbeat")]).optional(), name: z.string().optional(), sessionKey: z.string().optional(), messageTemplate: z.string().optional(), @@ -1274,9 +1202,7 @@ const HooksGmailSchema = z .optional(), tailscale: z .object({ - mode: z - .union([z.literal("off"), z.literal("serve"), z.literal("funnel")]) - .optional(), + mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(), path: z.string().optional(), target: z.string().optional(), }) @@ -1328,11 +1254,7 @@ const AgentDefaultsSchema = z contextPruning: z .object({ mode: z - .union([ - z.literal("off"), - z.literal("adaptive"), - z.literal("aggressive"), - ]) + .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(), @@ -1361,9 +1283,7 @@ const AgentDefaultsSchema = z .optional(), compaction: z .object({ - mode: z - .union([z.literal("default"), z.literal("safeguard")]) - .optional(), + mode: z.union([z.literal("default"), z.literal("safeguard")]).optional(), reserveTokensFloor: z.number().int().nonnegative().optional(), memoryFlush: z .object({ @@ -1387,12 +1307,8 @@ const AgentDefaultsSchema = z .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(), + 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(), @@ -1426,22 +1342,10 @@ const AgentDefaultsSchema = z .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(), + 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, diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 3aa8af1ac..8854908e7 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -26,15 +26,13 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted( - () => ({ - readTelegramAllowFromStore: vi.fn(async () => [] as string[]), - upsertTelegramPairingRequest: vi.fn(async () => ({ - code: "PAIRCODE", - created: true, - })), - }), -); +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, @@ -217,13 +215,9 @@ describe("createTelegramBot", () => { it("sequentializes updates by chat and thread", () => { createTelegramBot({ token: "tok" }); expect(sequentializeSpy).toHaveBeenCalledTimes(1); - expect(middlewareUseSpy).toHaveBeenCalledWith( - sequentializeSpy.mock.results[0]?.value, - ); + 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 } } })).toBe("telegram:123"); expect( getTelegramSequentialKey({ message: { chat: { id: 123 }, message_thread_id: 9 }, @@ -243,15 +237,13 @@ describe("createTelegramBot", () => { it("routes callback_query payloads as messages and answers callbacks", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); createTelegramBot({ token: "tok" }); - const callbackHandler = onSpy.mock.calls.find( - (call) => call[0] === "callback_query", - )?.[1] as (ctx: Record) => Promise; + const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + ctx: Record, + ) => Promise; expect(callbackHandler).toBeDefined(); await callbackHandler({ @@ -281,16 +273,12 @@ describe("createTelegramBot", () => { try { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); createTelegramBot({ token: "tok" }); expect(onSpy).toHaveBeenCalledWith("message", expect.any(Function)); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; const message = { chat: { id: 1234, type: "private" }, @@ -322,9 +310,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ @@ -337,9 +323,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -355,21 +339,15 @@ describe("createTelegramBot", () => { 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("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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ @@ -381,9 +359,7 @@ describe("createTelegramBot", () => { .mockResolvedValueOnce({ code: "PAIRME12", created: false }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; const message = { chat: { id: 1234, type: "private" }, @@ -412,9 +388,7 @@ describe("createTelegramBot", () => { sendChatActionSpy.mockReset(); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 42, type: "private" }, text: "hi" }, me: { username: "clawdbot_bot" }, @@ -426,9 +400,7 @@ describe("createTelegramBot", () => { it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ @@ -443,9 +415,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -462,16 +432,12 @@ describe("createTelegramBot", () => { 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\]/, - ); + 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ @@ -484,9 +450,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -515,9 +479,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ @@ -535,9 +497,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -551,9 +511,7 @@ describe("createTelegramBot", () => { getFile: async () => ({ download: async () => new Uint8Array() }), }); - expect(setMessageReactionSpy).toHaveBeenCalledWith(7, 123, [ - { type: "emoji", emoji: "👀" }, - ]); + expect(setMessageReactionSpy).toHaveBeenCalledWith(7, 123, [{ type: "emoji", emoji: "👀" }]); }); it("clears native commands when disabled", () => { @@ -568,9 +526,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ @@ -584,9 +540,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -605,9 +559,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ @@ -621,9 +573,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -645,15 +595,11 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -682,16 +628,12 @@ describe("createTelegramBot", () => { it("sends replies without native reply threading", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); replySpy.mockResolvedValue({ text: "a".repeat(4500) }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 5, type: "private" }, @@ -712,9 +654,7 @@ describe("createTelegramBot", () => { it("honors replyToMode=first for threaded replies", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); replySpy.mockResolvedValue({ text: "a".repeat(4500), @@ -722,9 +662,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok", replyToMode: "first" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 5, type: "private" }, @@ -747,9 +685,7 @@ describe("createTelegramBot", () => { it("prefixes tool and final replies with responsePrefix", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); replySpy.mockImplementation(async (_ctx, opts) => { await opts?.onToolResult?.({ text: "tool result" }); @@ -763,9 +699,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 5, type: "private" }, @@ -784,9 +718,7 @@ describe("createTelegramBot", () => { it("honors replyToMode=all for threaded replies", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); replySpy.mockResolvedValue({ text: "a".repeat(4500), @@ -794,9 +726,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok", replyToMode: "all" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 5, type: "private" }, @@ -816,9 +746,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -831,9 +759,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -850,9 +776,7 @@ describe("createTelegramBot", () => { it("skips group messages without mention when requireMention is enabled", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -861,9 +785,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -880,13 +802,9 @@ describe("createTelegramBot", () => { it("honors routed group activation from session store", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); - const storeDir = fs.mkdtempSync( - path.join(os.tmpdir(), "clawdbot-telegram-"), - ); + const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-telegram-")); const storePath = path.join(storeDir, "sessions.json"); fs.writeFileSync( storePath, @@ -915,9 +833,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -934,9 +850,7 @@ describe("createTelegramBot", () => { it("routes DMs by telegram accountId binding", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ @@ -959,9 +873,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok", accountId: "opie" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -983,9 +895,7 @@ describe("createTelegramBot", () => { it("allows per-group requireMention override", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1000,9 +910,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1019,9 +927,7 @@ describe("createTelegramBot", () => { it("allows per-topic requireMention override", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1041,9 +947,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1066,9 +970,7 @@ describe("createTelegramBot", () => { it("honors groups default when no explicit group override exists", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1080,9 +982,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1099,9 +999,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1113,9 +1011,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1131,9 +1027,7 @@ describe("createTelegramBot", () => { it("sends GIF replies as animations", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); replySpy.mockResolvedValueOnce({ @@ -1148,9 +1042,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1175,9 +1067,7 @@ describe("createTelegramBot", () => { // 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1189,9 +1079,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1210,9 +1098,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1224,9 +1110,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1244,9 +1128,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1259,9 +1141,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1279,9 +1159,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1294,9 +1172,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1314,9 +1190,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1329,9 +1203,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1349,9 +1221,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1364,9 +1234,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1384,9 +1252,7 @@ describe("createTelegramBot", () => { it("allows all group messages when groupPolicy is 'open'", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1398,9 +1264,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1418,9 +1282,7 @@ describe("createTelegramBot", () => { it("matches usernames case-insensitively when groupPolicy is 'allowlist'", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1433,9 +1295,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1453,9 +1313,7 @@ describe("createTelegramBot", () => { it("allows direct messages regardless of groupPolicy", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1467,9 +1325,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1487,9 +1343,7 @@ describe("createTelegramBot", () => { it("allows direct messages with tg/Telegram-prefixed allowFrom entries", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1500,9 +1354,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1520,9 +1372,7 @@ describe("createTelegramBot", () => { it("allows direct messages with telegram:-prefixed allowFrom entries", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1533,9 +1383,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1553,9 +1401,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1568,9 +1414,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1588,9 +1432,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1602,9 +1444,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1622,9 +1462,7 @@ describe("createTelegramBot", () => { it("matches telegram:-prefixed allowFrom entries in group allowlist", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1637,9 +1475,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1658,9 +1494,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1673,9 +1507,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1694,9 +1526,7 @@ describe("createTelegramBot", () => { it("blocks group messages when groupPolicy allowlist has no groupAllowFrom", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1708,9 +1538,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1728,9 +1556,7 @@ describe("createTelegramBot", () => { it("allows control commands with TG-prefixed groupAllowFrom entries", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { @@ -1743,9 +1569,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1764,9 +1588,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ @@ -1779,9 +1601,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1803,9 +1623,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; - expect(payload.SessionKey).toContain( - "telegram:group:-1001234567890:topic:99", - ); + 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); @@ -1817,9 +1635,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ @@ -1832,9 +1648,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1862,9 +1676,7 @@ describe("createTelegramBot", () => { 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 - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); replySpy.mockResolvedValue({ text: "response" }); @@ -1878,9 +1690,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1908,9 +1718,7 @@ describe("createTelegramBot", () => { it("applies topic skill filters and system prompts", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ @@ -1935,9 +1743,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -1968,9 +1774,7 @@ describe("createTelegramBot", () => { onSpy.mockReset(); sendMessageSpy.mockReset(); commandSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); replySpy.mockResolvedValue({ text: "response" }); @@ -1984,9 +1788,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { @@ -2017,9 +1819,7 @@ describe("createTelegramBot", () => { onSpy.mockReset(); sendMessageSpy.mockReset(); commandSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); replySpy.mockResolvedValue({ text: "response" }); @@ -2036,9 +1836,7 @@ describe("createTelegramBot", () => { createTelegramBot({ token: "tok" }); expect(commandSpy).toHaveBeenCalled(); - const handler = commandSpy.mock.calls[0][1] as ( - ctx: Record, - ) => Promise; + const handler = commandSpy.mock.calls[0][1] as (ctx: Record) => Promise; await handler({ message: { @@ -2068,9 +1866,7 @@ describe("createTelegramBot", () => { onSpy.mockReset(); sendMessageSpy.mockReset(); commandSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); replySpy.mockImplementation(async (_ctx, opts) => { await opts?.onToolResult?.({ text: "tool update" }); @@ -2086,9 +1882,9 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const verboseHandler = commandSpy.mock.calls.find( - (call) => call[0] === "verbose", - )?.[1] as ((ctx: Record) => Promise) | undefined; + 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({ @@ -2109,9 +1905,7 @@ describe("createTelegramBot", () => { it("dedupes duplicate message updates by update_id", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ @@ -2121,9 +1915,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as ( - ctx: Record, - ) => Promise; + const handler = getOnHandler("message") as (ctx: Record) => Promise; const ctx = { update: { update_id: 111 }, @@ -2146,9 +1938,7 @@ describe("createTelegramBot", () => { it("dedupes duplicate callback_query updates by update_id", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ @@ -2186,9 +1976,7 @@ describe("createTelegramBot", () => { it("allows distinct callback_query ids without update_id", async () => { onSpy.mockReset(); - const replySpy = replyModule.__replySpy as unknown as ReturnType< - typeof vi.fn - >; + const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); loadConfig.mockReturnValue({ @@ -2238,9 +2026,7 @@ describe("createTelegramBot", () => { it("registers message_reaction handler", () => { onSpy.mockReset(); createTelegramBot({ token: "tok" }); - const reactionHandler = onSpy.mock.calls.find( - (call) => call[0] === "message_reaction", - ); + const reactionHandler = onSpy.mock.calls.find((call) => call[0] === "message_reaction"); expect(reactionHandler).toBeDefined(); }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 2a02b7503..ffd50f7f9 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -17,15 +17,8 @@ import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, } from "../config/group-policy.js"; -import { - loadSessionStore, - resolveStorePath, - updateLastRoute, -} from "../config/sessions.js"; +import { loadSessionStore, resolveStorePath } 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 { resolveAgentRoute } from "../routing/resolve-route.js"; @@ -47,12 +40,6 @@ 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; @@ -334,23 +321,18 @@ export function createTelegramBot(opts: TelegramBotOptions) { // Detect added reactions const oldEmojis = new Set( reaction.old_reaction - .filter( - (r): r is { type: "emoji"; emoji: string } => r.type === "emoji", - ) + .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): 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 + ? [user.first_name, user.last_name].filter(Boolean).join(" ").trim() || user.username : undefined; const senderUsername = user?.username ? `@${user.username}` : undefined; let senderLabel = senderName; @@ -373,11 +355,8 @@ export function createTelegramBot(opts: TelegramBotOptions) { }); // Resolve agent route for session - const isGroup = - reaction.chat.type === "group" || reaction.chat.type === "supergroup"; - const peerId = isGroup - ? buildTelegramGroupPeerId(chatId, resolvedThreadId) - : String(chatId); + const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup"; + const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId); const route = resolveAgentRoute({ cfg, channel: "telegram", @@ -396,9 +375,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { logVerbose(`telegram: reaction event enqueued: ${text}`); } } catch (err) { - runtime.error?.( - danger(`telegram reaction handler failed: ${String(err)}`), - ); + runtime.error?.(danger(`telegram reaction handler failed: ${String(err)}`)); } }); diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index d0417669b..19b47058f 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -34,10 +34,7 @@ export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions { afterEach(() => { diff --git a/src/telegram/sent-message-cache.ts b/src/telegram/sent-message-cache.ts index 5951d4061..05c12ddf7 100644 --- a/src/telegram/sent-message-cache.ts +++ b/src/telegram/sent-message-cache.ts @@ -29,10 +29,7 @@ function cleanupExpired(entry: CacheEntry): void { /** * Record a message ID as sent by the bot. */ -export function recordSentMessage( - chatId: number | string, - messageId: number, -): void { +export function recordSentMessage(chatId: number | string, messageId: number): void { const key = getChatKey(chatId); let entry = sentMessages.get(key); if (!entry) { @@ -50,10 +47,7 @@ export function recordSentMessage( /** * Check if a message was sent by the bot. */ -export function wasSentByBot( - chatId: number | string, - messageId: number, -): boolean { +export function wasSentByBot(chatId: number | string, messageId: number): boolean { const key = getChatKey(chatId); const entry = sentMessages.get(key); if (!entry) return false; diff --git a/src/telegram/webhook.ts b/src/telegram/webhook.ts index 363aecec0..a6f31ec94 100644 --- a/src/telegram/webhook.ts +++ b/src/telegram/webhook.ts @@ -63,10 +63,7 @@ export async function startTelegramWebhook(opts: { await bot.api.setWebhook(publicUrl, { secret_token: opts.secret, - allowed_updates: [ - "message", - "message_reaction", - ], + allowed_updates: ["message", "message_reaction"], }); await new Promise((resolve) => server.listen(port, host, resolve)); From dfb6630de19a8f2f20a09c614c41ef9bfbcf5e6a Mon Sep 17 00:00:00 2001 From: Bohdan Podvirnyi Date: Thu, 15 Jan 2026 18:35:56 +0200 Subject: [PATCH 4/6] fix: remove dup definitions + add reaction config --- src/config/types.telegram.ts | 14 + src/config/types.ts | 1823 ----------------------- src/config/zod-schema.providers-core.ts | 4 + src/config/zod-schema.ts | 1350 ----------------- src/telegram/monitor.ts | 3 +- 5 files changed, 20 insertions(+), 3174 deletions(-) diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index a3c26d076..d140ce8d6 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -76,6 +76,20 @@ export type TelegramAccountConfig = { webhookPath?: string; /** Per-action tool gating (default: true for all). */ actions?: TelegramActionConfig; + /** + * Controls which user reactions trigger notifications: + * - "off" (default): ignore all reactions + * - "all": notify agent of all reactions + */ + reactionNotifications?: "off" | "all"; + /** + * Controls agent's reaction capability: + * - "off": agent cannot react + * - "ack" (default): bot sends acknowledgment reactions (👀 while processing) + * - "minimal": agent can react sparingly (guideline: 1 per 5-10 exchanges) + * - "extensive": agent can react liberally when appropriate + */ + reactionLevel?: "off" | "ack" | "minimal" | "extensive"; }; export type TelegramTopicConfig = { diff --git a/src/config/types.ts b/src/config/types.ts index fb0edc1ad..368618262 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -24,1826 +24,3 @@ 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>>; - -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 (default), all. */ - reactionNotifications?: "off" | "all"; - /** Reaction level: off, ack (default), minimal, extensive. */ - reactionLevel?: "off" | "ack" | "minimal" | "extensive"; - 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[]; -}; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 3d18799b3..46be90167 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -63,8 +63,12 @@ export const TelegramAccountSchemaBase = z.object({ actions: z .object({ reactions: z.boolean().optional(), + sendMessage: z.boolean().optional(), + deleteMessage: z.boolean().optional(), }) .optional(), + reactionNotifications: z.enum(["off", "all"]).optional(), + reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), }); export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index ed6140ace..0051bcae3 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -6,1356 +6,6 @@ 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", "all"]).optional(), - reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).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/monitor.ts b/src/telegram/monitor.ts index 19b47058f..a842d77c3 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -114,6 +114,8 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { }, }); + const log = opts.runtime?.log ?? console.log; + // When using polling mode, ensure no webhook is active if (!opts.useWebhook) { try { @@ -146,7 +148,6 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { } // Use grammyjs/runner for concurrent update processing - const log = opts.runtime?.log ?? console.log; let restartAttempts = 0; while (!opts.abortSignal?.aborted) { From f12c1b391f9a87a5bf21f20d3914ec46bd3cf202 Mon Sep 17 00:00:00 2001 From: Bohdan Podvirnyi Date: Thu, 15 Jan 2026 18:49:22 +0200 Subject: [PATCH 5/6] fix: lint errors --- src/auto-reply/commands-args.ts | 18 ++++++++++++------ src/auto-reply/commands-registry.ts | 22 +++++++++++++++++----- src/auto-reply/templating.ts | 6 ++++++ 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/auto-reply/commands-args.ts b/src/auto-reply/commands-args.ts index 63f73c468..7122d5457 100644 --- a/src/auto-reply/commands-args.ts +++ b/src/auto-reply/commands-args.ts @@ -4,14 +4,20 @@ export type CommandArgsFormatter = (values: CommandArgValues) => string | undefi function normalizeArgValue(value: unknown): string | undefined { if (value == null) return undefined; + let text: string; if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; + text = value.trim(); + } else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + text = String(value).trim(); + } else if (typeof value === "symbol") { + text = value.toString().trim(); + } else if (typeof value === "function") { + text = value.toString().trim(); + } else { + // Objects and arrays + text = JSON.stringify(value); } - if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { - return String(value); - } - return undefined; + return text ? text : undefined; } const formatConfigArgs: CommandArgsFormatter = (values) => { diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 5edc677d2..4da2f9869 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -137,13 +137,25 @@ function formatPositionalArgs( for (const definition of definitions) { const value = values[definition.name]; if (value == null) continue; + let rendered: string; if (typeof value === "string") { - const trimmed = value.trim(); - if (!trimmed) continue; - parts.push(trimmed); - } else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { - parts.push(String(value)); + rendered = value.trim(); + } else if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + rendered = String(value); + } else if (typeof value === "symbol") { + rendered = value.toString(); + } else if (typeof value === "function") { + rendered = value.toString(); + } else { + // Objects and arrays + rendered = JSON.stringify(value); } + if (!rendered) continue; + parts.push(rendered); if (definition.captureRemaining) break; } return parts.length > 0 ? parts.join(" ") : undefined; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index e110e5a9b..647088460 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -84,6 +84,9 @@ function formatTemplateValue(value: unknown): string { if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { return String(value); } + if (typeof value === "symbol" || typeof value === "function") { + return value.toString(); + } if (Array.isArray(value)) { return value .flatMap((entry) => { @@ -96,6 +99,9 @@ function formatTemplateValue(value: unknown): string { }) .join(","); } + if (typeof value === "object") { + return JSON.stringify(value); + } return ""; } From 2b1c26f900d343be9f4c6d52ea5578a3d608e2af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 17:20:17 +0000 Subject: [PATCH 6/6] fix: refine telegram reactions (#964) (thanks @bohdanpodvirnyi) --- CHANGELOG.md | 1 + docs/channels/telegram.md | 7 +- src/agents/pi-embedded-runner/run/attempt.ts | 13 +++ .../pi-embedded-runner/system-prompt.ts | 5 + src/agents/system-prompt.test.ts | 13 +++ src/config/types.telegram.ts | 3 +- src/config/zod-schema.providers-core.ts | 2 +- src/telegram/allowed-updates.ts | 11 +++ src/telegram/bot.test.ts | 94 +++++++++++++++++++ src/telegram/bot.ts | 3 + src/telegram/monitor.ts | 23 +---- src/telegram/webhook.test.ts | 7 +- src/telegram/webhook.ts | 3 +- 13 files changed, 157 insertions(+), 28 deletions(-) create mode 100644 src/telegram/allowed-updates.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0452e772b..592a70847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2026.1.15 (unreleased) - Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4. +- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi. ## 2026.1.14-1 diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 74e4a24f0..5862e908c 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -312,11 +312,12 @@ The agent sees reactions as **system notifications** in the conversation history **Configuration:** - `channels.telegram.reactionNotifications`: Controls which reactions trigger notifications - `"off"` — ignore all reactions (default when not set) + - `"own"` — notify when users react to bot messages (best-effort; in-memory) - `"all"` — notify for all reactions - `channels.telegram.reactionLevel`: Controls agent's reaction capability - `"off"` — agent cannot react to messages - - `"ack"` — bot sends acknowledgment reactions (👀 while processing) + - `"ack"` — bot sends acknowledgment reactions (👀 while processing) (default) - `"minimal"` — agent can react sparingly (guideline: 1 per 5-10 exchanges) - `"extensive"` — agent can react liberally when appropriate @@ -402,8 +403,8 @@ Provider options: - `channels.telegram.actions.reactions`: gate Telegram tool reactions. - `channels.telegram.actions.sendMessage`: gate Telegram tool message sends. - `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes. -- `channels.telegram.reactionNotifications`: `off | all` — control which reactions trigger system events (default: `off` when not set). -- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `off` when not set). +- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `off` when not set). +- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `ack` when not set). Related global options: - `agents.list[].groupChat.mentionPatterns` (mention gating patterns). diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 93e881c59..0662cf56d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -9,6 +9,7 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import { getMachineDisplayName } from "../../../infra/machine-name.js"; +import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js"; import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; import { resolveUserPath } from "../../../utils.js"; @@ -161,6 +162,17 @@ export async function runEmbeddedAttempt( accountId: params.agentAccountId, }) ?? []) : undefined; + const reactionGuidance = + runtimeChannel === "telegram" && params.config + ? (() => { + const resolved = resolveTelegramReactionLevel({ + cfg: params.config, + accountId: params.agentAccountId ?? undefined, + }); + const level = resolved.agentReactionGuidance; + return level ? { level, channel: "Telegram" } : undefined; + })() + : undefined; const runtimeInfo = { host: machineName, os: `${os.type()} ${os.release()}`, @@ -192,6 +204,7 @@ export async function runEmbeddedAttempt( ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) : undefined, skillsPrompt, + reactionGuidance, runtimeInfo, sandboxInfo, tools, diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/pi-embedded-runner/system-prompt.ts index 4aa4867e3..0c07006ce 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/pi-embedded-runner/system-prompt.ts @@ -14,6 +14,10 @@ export function buildEmbeddedSystemPrompt(params: { reasoningTagHint: boolean; heartbeatPrompt?: string; skillsPrompt?: string; + reactionGuidance?: { + level: "minimal" | "extensive"; + channel: string; + }; runtimeInfo: { host: string; os: string; @@ -40,6 +44,7 @@ export function buildEmbeddedSystemPrompt(params: { reasoningTagHint: params.reasoningTagHint, heartbeatPrompt: params.heartbeatPrompt, skillsPrompt: params.skillsPrompt, + reactionGuidance: params.reactionGuidance, runtimeInfo: params.runtimeInfo, sandboxInfo: params.sandboxInfo, toolNames: params.tools.map((tool) => tool.name), diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 260c7c631..7a43b17dc 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -208,4 +208,17 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("User can toggle with /elevated on|off."); expect(prompt).toContain("Current elevated level: on"); }); + + it("includes reaction guidance when provided", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/clawd", + reactionGuidance: { + level: "minimal", + channel: "Telegram", + }, + }); + + expect(prompt).toContain("## Reactions"); + expect(prompt).toContain("Reactions are enabled for Telegram in MINIMAL mode."); + }); }); diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index d140ce8d6..29ad277dd 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -79,9 +79,10 @@ export type TelegramAccountConfig = { /** * Controls which user reactions trigger notifications: * - "off" (default): ignore all reactions + * - "own": notify when users react to bot messages * - "all": notify agent of all reactions */ - reactionNotifications?: "off" | "all"; + reactionNotifications?: "off" | "own" | "all"; /** * Controls agent's reaction capability: * - "off": agent cannot react diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 46be90167..4bccca5f9 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -67,7 +67,7 @@ export const TelegramAccountSchemaBase = z.object({ deleteMessage: z.boolean().optional(), }) .optional(), - reactionNotifications: z.enum(["off", "all"]).optional(), + reactionNotifications: z.enum(["off", "own", "all"]).optional(), reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), }); diff --git a/src/telegram/allowed-updates.ts b/src/telegram/allowed-updates.ts new file mode 100644 index 000000000..e32fefd09 --- /dev/null +++ b/src/telegram/allowed-updates.ts @@ -0,0 +1,11 @@ +import { API_CONSTANTS } from "grammy"; + +type TelegramUpdateType = (typeof API_CONSTANTS.ALL_UPDATE_TYPES)[number]; + +export function resolveTelegramAllowedUpdates(): ReadonlyArray { + const updates = [...API_CONSTANTS.DEFAULT_UPDATE_TYPES] as TelegramUpdateType[]; + if (!updates.includes("message_reaction")) { + updates.push("message_reaction"); + } + return updates; +} diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 8854908e7..a218adc4d 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -151,6 +151,7 @@ describe("createTelegramBot", () => { setMessageReactionSpy.mockReset(); answerCallbackQuerySpy.mockReset(); setMyCommandsSpy.mockReset(); + wasSentByBot.mockReset(); middlewareUseSpy.mockReset(); sequentializeSpy.mockReset(); botCtorSpy.mockReset(); @@ -2132,6 +2133,99 @@ describe("createTelegramBot", () => { ); }); + it("skips reaction in own mode when message is 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: 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).not.toHaveBeenCalled(); + }); + + it("allows reaction in own mode when message is sent by bot", 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: 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); + }); + + it("skips reaction from bot users", async () => { + onSpy.mockReset(); + enqueueSystemEvent.mockReset(); + wasSentByBot.mockReturnValue(true); + + 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: "Bot", is_bot: true }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "🎉" }], + }, + }); + + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + }); + it("skips reaction removal (only processes added reactions)", async () => { onSpy.mockReset(); enqueueSystemEvent.mockReset(); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index ffd50f7f9..31030516c 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -40,6 +40,7 @@ import { type TelegramUpdateKeyContext, } from "./bot-updates.js"; import { resolveTelegramFetch } from "./fetch.js"; +import { wasSentByBot } from "./sent-message-cache.js"; export type TelegramBotOptions = { token: string; @@ -317,6 +318,8 @@ export function createTelegramBot(opts: TelegramBotOptions) { // Resolve reaction notification mode (default: "off") const reactionMode = telegramCfg.reactionNotifications ?? "off"; if (reactionMode === "off") return; + if (user?.is_bot) return; + if (reactionMode === "own" && !wasSentByBot(chatId, messageId)) return; // Detect added reactions const oldEmojis = new Set( diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index a842d77c3..bb8d6adb0 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -5,6 +5,7 @@ import { computeBackoff, sleepWithAbort } from "../infra/backoff.js"; import { formatDurationMs } from "../infra/format-duration.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveTelegramAccount } from "./accounts.js"; +import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { createTelegramBot } from "./bot.js"; import { makeProxyFetch } from "./proxy.js"; import { readTelegramUpdateOffset, writeTelegramUpdateOffset } from "./update-offset-store.js"; @@ -33,8 +34,8 @@ export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions ({ stop: stopSpy, })); -vi.mock("grammy", () => ({ - webhookCallback: () => handlerSpy, -})); +vi.mock("grammy", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, webhookCallback: () => handlerSpy }; +}); vi.mock("./bot.js", () => ({ createTelegramBot: (...args: unknown[]) => createTelegramBotSpy(...args), diff --git a/src/telegram/webhook.ts b/src/telegram/webhook.ts index a6f31ec94..6e2310b8b 100644 --- a/src/telegram/webhook.ts +++ b/src/telegram/webhook.ts @@ -5,6 +5,7 @@ import type { ClawdbotConfig } from "../config/config.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; +import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { createTelegramBot } from "./bot.js"; export async function startTelegramWebhook(opts: { @@ -63,7 +64,7 @@ export async function startTelegramWebhook(opts: { await bot.api.setWebhook(publicUrl, { secret_token: opts.secret, - allowed_updates: ["message", "message_reaction"], + allowed_updates: resolveTelegramAllowedUpdates(), }); await new Promise((resolve) => server.listen(port, host, resolve));