From 17d052bcdacf8922e1534d0c11468af38488f461 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 00:50:29 +0000 Subject: [PATCH] fix: polish reply threading + tool dedupe (thanks @mneves75) (#326) --- PR-326-REVIEW.md | 195 ------------------- docs/gateway/configuration.md | 4 +- docs/providers/slack.md | 9 + docs/providers/telegram.md | 12 +- docs/telegram.md | 130 ------------- docs/tools/index.md | 3 +- src/agents/pi-embedded-runner.ts | 4 + src/agents/pi-embedded-subscribe.ts | 1 + src/auto-reply/reply/agent-runner.ts | 55 ++++-- src/auto-reply/reply/followup-runner.ts | 16 +- src/auto-reply/reply/reply-threading.test.ts | 53 +++++ src/auto-reply/reply/reply-threading.ts | 36 ++++ src/auto-reply/reply/route-reply.test.ts | 15 ++ src/auto-reply/reply/route-reply.ts | 7 + src/config/types.ts | 2 + src/config/zod-schema.ts | 1 + 16 files changed, 193 insertions(+), 350 deletions(-) delete mode 100644 PR-326-REVIEW.md delete mode 100644 docs/telegram.md create mode 100644 src/auto-reply/reply/reply-threading.test.ts create mode 100644 src/auto-reply/reply/reply-threading.ts diff --git a/PR-326-REVIEW.md b/PR-326-REVIEW.md deleted file mode 100644 index 59c8e1f71..000000000 --- a/PR-326-REVIEW.md +++ /dev/null @@ -1,195 +0,0 @@ -# PR #326 Final Review - -**Reviewer:** Claude Opus 4.5 -**Date:** 2026-01-07 -**PR:** https://github.com/clawdbot/clawdbot/pull/326 -**Commits:** ecd606ec, 94f7846a -**Branch:** fix/telegram-replyto-default-v2 - ---- - -## Summary - -This PR implements three focused improvements: -1. Telegram `replyToMode` default change: `"off"` → `"first"` -2. Forum topic support via `messageThreadId` and `replyToMessageId` -3. Messaging tool duplicate suppression - -## Scope Verification ✅ - -**15 files changed, +675 −38 lines** - -| File | Purpose | -|------|---------| -| `CHANGELOG.md` | Changelog entries | -| `docs/telegram.md` | New comprehensive documentation | -| `src/agents/pi-embedded-helpers.ts` | Duplicate detection helpers | -| `src/agents/pi-embedded-helpers.test.ts` | Tests for normalization | -| `src/agents/pi-embedded-runner.ts` | Exposes `didSendViaMessagingTool` | -| `src/agents/pi-embedded-subscribe.ts` | Messaging tool tracking | -| `src/agents/tools/telegram-actions.ts` | sendMessage action handler | -| `src/agents/tools/telegram-actions.test.ts` | Tests for sendMessage | -| `src/agents/tools/telegram-schema.ts` | Schema for sendMessage | -| `src/agents/tools/telegram-tool.ts` | Updated description | -| `src/auto-reply/reply/agent-runner.ts` | Suppression logic | -| `src/config/types.ts` | sendMessage action config | -| `src/telegram/bot.ts` | replyToMode default change | -| `src/telegram/send.ts` | Core thread params implementation | -| `src/telegram/send.test.ts` | Tests for thread params | - -## Type Safety ✅ - -### Critical Fix: Removed `// @ts-nocheck` - -The file `src/telegram/send.ts` had `// @ts-nocheck` which was hiding 17+ TypeScript errors. This has been properly fixed: - -```typescript -// BEFORE (hiding errors) -// @ts-nocheck -const bot = opts.api ? null : new Bot(token); -const api = opts.api ?? bot?.api; // api could be undefined! - -// AFTER (type-safe) -import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types"; -const api = opts.api ?? new Bot(token).api; // Always defined -``` - -### Reaction Type Fix - -```typescript -// Proper typing for reaction emoji -const reactions: ReactionType[] = - remove || !trimmedEmoji - ? [] - : [{ type: "emoji", emoji: trimmedEmoji as ReactionTypeEmoji["emoji"] }]; -``` - -## Logic Correctness ✅ - -### 1. Duplicate Detection - -The duplicate detection system uses a two-phase approach: - -```typescript -// Only committed (successful) texts are checked - not pending -// Prevents message loss if tool fails after suppression -const messagingToolSentTexts: string[] = []; -const pendingMessagingTexts = new Map(); -``` - -**Normalization:** -- Trims whitespace -- Lowercases -- Strips emoji (Emoji_Presentation and Extended_Pictographic) -- Collapses multiple spaces - -**Matching:** -- Minimum length check (10 chars) prevents false positives -- Substring matching handles LLM elaboration in both directions - -### 2. Thread Parameters - -Thread params are built conditionally to keep API calls clean: - -```typescript -const threadParams: Record = {}; -if (opts.messageThreadId != null) { - threadParams.message_thread_id = opts.messageThreadId; -} -if (opts.replyToMessageId != null) { - threadParams.reply_to_message_id = opts.replyToMessageId; -} -const hasThreadParams = Object.keys(threadParams).length > 0; -``` - -### 3. Suppression Logic - -```typescript -// Drop final payloads if: -// 1. Block streaming is enabled and we already streamed block replies, OR -// 2. A messaging tool successfully sent the response -const shouldDropFinalPayloads = - (blockStreamingEnabled && didStreamBlockReply) || - runResult.didSendViaMessagingTool === true; -``` - -## Test Coverage ✅ - -| Test Suite | Cases Added | -|------------|-------------| -| `normalizeTextForComparison` | 5 | -| `isMessagingToolDuplicate` | 7 | -| `sendMessageTelegram` thread params | 5 | -| `handleTelegramAction` sendMessage | 4 | -| Forum topic isolation (bot.test.ts) | 4 | - -**Total tests passing:** 1309 - -## Edge Cases Handled ✅ - -| Edge Case | Handling | -|-----------|----------| -| Empty sentTexts array | Returns false | -| Short texts (< 10 chars) | Returns false (prevents false positives) | -| LLM elaboration | Substring matching in both directions | -| Emoji variations | Normalized away before comparison | -| Markdown parse errors | Fallback preserves thread params | -| Missing thread params | Clean API calls (no empty object spread) | - -## Documentation ✅ - -New file `docs/telegram.md` (130 lines) covers: -- Setup with BotFather -- Forum topics (supergroups) -- Reply modes (`"first"`, `"all"`, `"off"`) -- Access control (DM policy, group policy) -- Mention requirements -- Media handling - -Includes YAML frontmatter for discoverability: -```yaml -summary: "Telegram Bot API integration: setup, forum topics, reply modes, and configuration" -read_when: - - Configuring Telegram bot integration - - Setting up forum topic threading - - Troubleshooting Telegram reply behavior -``` - -## Build Status ✅ - -``` -Tests: 1309 passing -Lint: 0 errors -Build: Clean (tsc) -``` - -## Post-Review Fix (94f7846a) - -**Issue:** CI build failed with `Cannot find module '@grammyjs/types'` - -**Root Cause:** The import `import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types"` requires `@grammyjs/types` as an explicit devDependency. While grammy installs it as a transitive dependency, TypeScript cannot resolve it without an explicit declaration. - -**Fix:** Added `@grammyjs/types` as a devDependency in package.json. - -```diff -+ "@grammyjs/types": "^3.23.0", -``` - -This is the correct fix because: -1. grammy's types.node.d.ts does `export * from "@grammyjs/types"` -2. Type-only imports need the package explicitly declared for TypeScript resolution -3. This is a standard pattern in the grammy ecosystem - -## Verdict: READY FOR PRODUCTION - -The code meets John Carmack standards: - -- **Clarity** over cleverness - Code is readable and well-commented -- **Correctness** first - Edge cases properly handled -- **Type safety** without cheating - `@ts-nocheck` removed and fixed -- **Focused scope** - No unnecessary changes or scope creep -- **Comprehensive testing** - All new functionality covered - ---- - -*Review conducted by Claude Opus 4.5 on 2026-01-07* diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 348354b25..f61e06004 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -582,8 +582,9 @@ Set `telegram.enabled: false` to disable automatic startup. } } }, + replyToMode: "first", // off | first | all streamMode: "partial", // off | partial | block (draft streaming) - actions: { reactions: true }, // tool action gates (false disables) + actions: { reactions: true, sendMessage: true }, // tool action gates (false disables) mediaMaxMb: 5, retry: { // outbound retry policy attempts: 3, @@ -707,6 +708,7 @@ Slack runs in Socket Mode and requires both a bot token and app token: }, reactionNotifications: "own", // off | own | all | allowlist reactionAllowlist: ["U123"], + replyToMode: "off", // off | first | all actions: { reactions: true, messages: true, diff --git a/docs/providers/slack.md b/docs/providers/slack.md index 6fd40e5e2..3b8e1ca5b 100644 --- a/docs/providers/slack.md +++ b/docs/providers/slack.md @@ -167,6 +167,7 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens: }, "reactionNotifications": "own", "reactionAllowlist": ["U123"], + "replyToMode": "off", "actions": { "reactions": true, "messages": true, @@ -193,6 +194,14 @@ Tokens can also be supplied via env vars: Ack reactions are controlled globally via `messages.ackReaction` + `messages.ackReactionScope`. +## Reply threading +Slack supports optional threaded replies via tags: +- `[[reply_to_current]]` — reply to the triggering message. +- `[[reply_to:]]` — reply to a specific message id. + +Controlled by `slack.replyToMode`: +- `off` (default), `first`, `all`. + ## Sessions + routing - DMs share the `main` session (like WhatsApp/Telegram). - Channels map to `slack:channel:` sessions. diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md index a8722481f..a5a7aca3e 100644 --- a/docs/providers/telegram.md +++ b/docs/providers/telegram.md @@ -5,7 +5,7 @@ read_when: --- # Telegram (Bot API) -Updated: 2026-01-07 +Updated: 2026-01-08 Status: production-ready for bot DMs + groups via grammY. Long-polling by default; webhook optional. @@ -139,7 +139,7 @@ Telegram supports optional threaded replies via tags: - `[[reply_to:]]` -- reply to a specific message id. Controlled by `telegram.replyToMode`: -- `off` (default), `first`, `all`. +- `first` (default), `all`, `off`. ## Streaming (drafts) Telegram can stream **draft bubbles** while the agent is generating a response. @@ -166,10 +166,11 @@ More context: [Streaming + chunking](/concepts/streaming). ## Retry policy Outbound Telegram API calls retry on transient network/429 errors with exponential backoff and jitter. Configure via `telegram.retry`. See [Retry policy](/concepts/retry). -## Agent tool (reactions) +## Agent tool (messages + reactions) +- Tool: `telegram` with `sendMessage` action (`to`, `content`, optional `mediaUrl`, `replyToMessageId`, `messageThreadId`). - Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`). - Reaction removal semantics: see [/tools/reactions](/tools/reactions). -- Tool gating: `telegram.actions.reactions` (default: enabled). +- Tool gating: `telegram.actions.reactions` and `telegram.actions.sendMessage` (default: enabled). ## Delivery targets (CLI/cron) - Use a chat id (`123456789`) or a username (`@name`) as the target. @@ -215,7 +216,7 @@ Provider options: - `telegram.groups..enabled`: disable the group when `false`. - `telegram.groups..topics..*`: per-topic overrides (same fields as group). - `telegram.groups..topics..requireMention`: per-topic mention gating override. -- `telegram.replyToMode`: `off | first | all`. +- `telegram.replyToMode`: `off | first | all` (default: `first`). - `telegram.textChunkLimit`: outbound chunk size (chars). - `telegram.streamMode`: `off | partial | block` (draft streaming). - `telegram.mediaMaxMb`: inbound/outbound media cap (MB). @@ -225,6 +226,7 @@ Provider options: - `telegram.webhookSecret`: webhook secret (optional). - `telegram.webhookPath`: local webhook path (default `/telegram-webhook`). - `telegram.actions.reactions`: gate Telegram tool reactions. +- `telegram.actions.sendMessage`: gate Telegram tool message sends. Related global options: - `routing.groupChat.mentionPatterns` (mention gating patterns). diff --git a/docs/telegram.md b/docs/telegram.md deleted file mode 100644 index c6dddd537..000000000 --- a/docs/telegram.md +++ /dev/null @@ -1,130 +0,0 @@ ---- -summary: "Telegram Bot API integration: setup, forum topics, reply modes, and configuration" -read_when: - - Configuring Telegram bot integration - - Setting up forum topic threading - - Troubleshooting Telegram reply behavior ---- -# Telegram Integration - -CLAWDBOT connects to Telegram via the [Bot API](https://core.telegram.org/bots/api) using [grammY](https://grammy.dev/). - -## Setup - -1. Create a bot via [@BotFather](https://t.me/BotFather) -2. Copy the token -3. Add to your config: - -```json -{ - "telegram": { - "token": "123456789:ABCdefGHI..." - } -} -``` - -Or set `TELEGRAM_BOT_TOKEN` in your environment. - -## Forum Topics (Supergroups) - -Telegram supergroups can enable **Topics** (forum mode), which creates thread-like conversations within a single group. CLAWDBOT fully supports forum topics: - -- **Automatic detection:** When a message arrives from a forum topic, CLAWDBOT automatically routes it to a topic-specific session -- **Thread isolation:** Each topic gets its own conversation context, so the agent maintains separate threads -- **Reply threading:** Replies are sent to the same topic via `message_thread_id` - -### Session Routing - -Forum topic messages create session keys in the format: -``` -telegram:group::topic: -``` - -This ensures conversations in different topics remain isolated even within the same supergroup. - -## Reply Modes - -The `replyToMode` setting controls how the bot replies to messages: - -| Mode | Behavior | -|------|----------| -| `"first"` | Reply to the first message in a conversation (default) | -| `"all"` | Reply to every message | -| `"off"` | Send messages without reply threading | - -Configure in your config: - -```json -{ - "telegram": { - "replyToMode": "first" - } -} -``` - -**Default:** `"first"` — This ensures replies appear threaded in the chat, making conversations easier to follow. - -## Access Control - -### DM Policy - -Control who can DM your bot: - -```json -{ - "telegram": { - "dmPolicy": "pairing", - "allowFrom": ["123456789", "@username"] - } -} -``` - -- `"pairing"` (default): New users get a pairing code to request access -- `"allowlist"`: Only users in `allowFrom` can interact -- `"open"`: Anyone can DM the bot -- `"disabled"`: DMs are blocked - -### Group Policy - -Control group message handling: - -```json -{ - "telegram": { - "groupPolicy": "open", - "groupAllowFrom": ["*"], - "groups": ["-1001234567890"] - } -} -``` - -- `groupPolicy`: `"open"` (default), `"allowlist"`, or `"disabled"` -- `groups`: When set, acts as an allowlist of group IDs - -## Mention Requirements - -In groups, you can require the bot to be mentioned: - -```json -{ - "telegram": { - "requireMention": true - } -} -``` - -When `true`, the bot only responds to messages that @mention it or match configured mention patterns. - -## Media Handling - -Configure media size limits: - -```json -{ - "telegram": { - "mediaMaxMb": 10 - } -} -``` - -Default: 5MB. Files exceeding this limit are rejected with a user-friendly message. diff --git a/docs/tools/index.md b/docs/tools/index.md index 029789a86..1d71039b4 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -241,9 +241,10 @@ Notes: - The tool is only exposed when the current provider is WhatsApp. ### `telegram` -Send Telegram reactions. +Send Telegram messages or reactions. Core actions: +- `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyToMessageId`, `messageThreadId`) - `react` (`chatId`, `messageId`, `emoji`, optional `remove`) Notes: diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index b3aea4fd4..5ad2a4917 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -226,6 +226,8 @@ export type EmbeddedPiRunResult = { // True if a messaging tool (telegram, whatsapp, discord, slack, sessions_send) // successfully sent a message. Used to suppress agent's confirmation text. didSendViaMessagingTool?: boolean; + // Texts successfully sent via messaging tools during the run. + messagingToolSentTexts?: string[]; }; export type EmbeddedPiCompactResult = { @@ -1253,6 +1255,7 @@ export async function runEmbeddedPiAgent(params: { toolMetas, unsubscribe, waitForCompactionRetry, + getMessagingToolSentTexts, didSendViaMessagingTool, } = subscription; @@ -1536,6 +1539,7 @@ export async function runEmbeddedPiAgent(params: { aborted, }, didSendViaMessagingTool: didSendViaMessagingTool(), + messagingToolSentTexts: getMessagingToolSentTexts(), }; } finally { restoreSkillEnv?.(); diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index bb17faa08..20d4ebc46 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -778,6 +778,7 @@ export function subscribeEmbeddedPiSession(params: { toolMetas, unsubscribe, isCompacting: () => compactionInFlight || pendingCompactionRetry > 0, + getMessagingToolSentTexts: () => messagingToolSentTexts.slice(), // Returns true if any messaging tool successfully sent a message. // Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!") // which is generated AFTER the tool sends the actual answer. diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 804cd0073..abbf28685 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -7,6 +7,7 @@ import { queueEmbeddedPiMessage, runEmbeddedPiAgent, } from "../../agents/pi-embedded.js"; +import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js"; import { hasNonzeroUsage } from "../../agents/usage.js"; import { loadSessionStore, @@ -19,7 +20,7 @@ import { logVerbose } from "../../globals.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; import { stripHeartbeatToken } from "../heartbeat.js"; -import type { TemplateContext } from "../templating.js"; +import type { OriginatingChannelType, TemplateContext } from "../templating.js"; import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; @@ -31,6 +32,10 @@ import { scheduleFollowupDrain, } from "./queue.js"; import { extractReplyToTag } from "./reply-tags.js"; +import { + createReplyToModeFilter, + resolveReplyToMode, +} from "./reply-threading.js"; import { incrementCompactionCount } from "./session-updates.js"; import type { TypingController } from "./typing.js"; import { createTypingSignaler } from "./typing-mode.js"; @@ -147,6 +152,16 @@ export async function runReplyAgent(params: { replyToId: payload.replyToId ?? null, }); }; + const replyToChannel = + sessionCtx.OriginatingChannel ?? + ((sessionCtx.Surface ?? sessionCtx.Provider)?.toLowerCase() as + | OriginatingChannelType + | undefined); + const replyToMode = resolveReplyToMode( + followupRun.run.config, + replyToChannel, + ); + const applyReplyToMode = createReplyToModeFilter(replyToMode); if (shouldSteer && isStreaming) { const steered = queueEmbeddedPiMessage( @@ -315,13 +330,12 @@ export async function runReplyAgent(params: { if (!cleaned && !hasMedia) return; if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia) return; - const blockPayload: ReplyPayload = { + const blockPayload: ReplyPayload = applyReplyToMode({ text: cleaned, mediaUrls: payload.mediaUrls, mediaUrl: payload.mediaUrls?.[0], - // Default to incoming message ID for threading support (replyToMode: "first"|"all") - replyToId: tagResult.replyToId ?? sessionCtx.MessageSid, - }; + replyToId: tagResult.replyToId, + }); const payloadKey = buildPayloadKey(blockPayload); if ( streamedPayloadKeys.has(payloadKey) || @@ -502,8 +516,7 @@ export async function runReplyAgent(params: { return { ...payload, text: cleaned ? cleaned : undefined, - // Default to incoming message ID for threading support (replyToMode: "first"|"all") - replyToId: replyToId ?? payload.replyToId ?? sessionCtx.MessageSid, + replyToId: replyToId ?? payload.replyToId, }; }) .filter( @@ -511,23 +524,31 @@ export async function runReplyAgent(params: { payload.text || payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0), - ); + ) + .map(applyReplyToMode); - // Drop final payloads if: - // 1. Block streaming is enabled and we already streamed block replies, OR - // 2. A messaging tool (telegram, whatsapp, etc.) successfully sent the response. - // The agent often generates confirmation text (e.g., "Respondi no Telegram!") - // AFTER using the messaging tool - we must suppress this confirmation text. + // Drop final payloads if block streaming is enabled and we already streamed + // block replies. Tool-sent duplicates are filtered below. const shouldDropFinalPayloads = - (blockStreamingEnabled && didStreamBlockReply) || - runResult.didSendViaMessagingTool === true; + blockStreamingEnabled && didStreamBlockReply; + const messagingToolSentTexts = runResult.messagingToolSentTexts ?? []; + const dedupedPayloads = + messagingToolSentTexts.length > 0 + ? replyTaggedPayloads.filter( + (payload) => + !isMessagingToolDuplicate( + payload.text ?? "", + messagingToolSentTexts, + ), + ) + : replyTaggedPayloads; const filteredPayloads = shouldDropFinalPayloads ? [] : blockStreamingEnabled - ? replyTaggedPayloads.filter( + ? dedupedPayloads.filter( (payload) => !streamedPayloadKeys.has(buildPayloadKey(payload)), ) - : replyTaggedPayloads; + : dedupedPayloads; if (filteredPayloads.length === 0) return finalizeWithFollowup(undefined); diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 46fd90884..eebdf5e45 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -10,10 +10,15 @@ import { logVerbose } from "../../globals.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; import { stripHeartbeatToken } from "../heartbeat.js"; +import type { OriginatingChannelType } from "../templating.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import type { FollowupRun } from "./queue.js"; import { extractReplyToTag } from "./reply-tags.js"; +import { + createReplyToModeFilter, + resolveReplyToMode, +} from "./reply-threading.js"; import { isRoutableChannel, routeReply } from "./route-reply.js"; import { incrementCompactionCount } from "./session-updates.js"; import type { TypingController } from "./typing.js"; @@ -179,6 +184,14 @@ export function createFollowupRunner(params: { if (stripped.shouldSkip && !hasMedia) return []; return [{ ...payload, text: stripped.text }]; }); + const replyToChannel = + queued.originatingChannel ?? + (queued.run.messageProvider?.toLowerCase() as + | OriginatingChannelType + | undefined); + const applyReplyToMode = createReplyToModeFilter( + resolveReplyToMode(queued.run.config, replyToChannel), + ); const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads .map((payload) => { @@ -194,7 +207,8 @@ export function createFollowupRunner(params: { payload.text || payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0), - ); + ) + .map(applyReplyToMode); if (replyTaggedPayloads.length === 0) return; diff --git a/src/auto-reply/reply/reply-threading.test.ts b/src/auto-reply/reply/reply-threading.test.ts new file mode 100644 index 000000000..19b0ea3a0 --- /dev/null +++ b/src/auto-reply/reply/reply-threading.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import { + createReplyToModeFilter, + resolveReplyToMode, +} from "./reply-threading.js"; + +const emptyCfg = {} as ClawdbotConfig; + +describe("resolveReplyToMode", () => { + it("defaults to first for Telegram", () => { + expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("first"); + }); + + it("defaults to off for Discord and Slack", () => { + expect(resolveReplyToMode(emptyCfg, "discord")).toBe("off"); + expect(resolveReplyToMode(emptyCfg, "slack")).toBe("off"); + }); + + it("defaults to all when channel is unknown", () => { + expect(resolveReplyToMode(emptyCfg, undefined)).toBe("all"); + }); + + it("uses configured value when present", () => { + const cfg = { + telegram: { replyToMode: "all" }, + discord: { replyToMode: "first" }, + slack: { replyToMode: "all" }, + } as ClawdbotConfig; + expect(resolveReplyToMode(cfg, "telegram")).toBe("all"); + expect(resolveReplyToMode(cfg, "discord")).toBe("first"); + expect(resolveReplyToMode(cfg, "slack")).toBe("all"); + }); +}); + +describe("createReplyToModeFilter", () => { + it("drops replyToId when mode is off", () => { + const filter = createReplyToModeFilter("off"); + expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined(); + }); + + it("keeps replyToId when mode is all", () => { + const filter = createReplyToModeFilter("all"); + expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1"); + }); + + it("keeps only the first replyToId when mode is first", () => { + const filter = createReplyToModeFilter("first"); + expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1"); + expect(filter({ text: "next", replyToId: "1" }).replyToId).toBeUndefined(); + }); +}); diff --git a/src/auto-reply/reply/reply-threading.ts b/src/auto-reply/reply/reply-threading.ts new file mode 100644 index 000000000..af84bcb7d --- /dev/null +++ b/src/auto-reply/reply/reply-threading.ts @@ -0,0 +1,36 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import type { ReplyToMode } from "../../config/types.js"; +import type { OriginatingChannelType } from "../templating.js"; +import type { ReplyPayload } from "../types.js"; + +export function resolveReplyToMode( + cfg: ClawdbotConfig, + channel?: OriginatingChannelType, +): ReplyToMode { + switch (channel) { + case "telegram": + return cfg.telegram?.replyToMode ?? "first"; + case "discord": + return cfg.discord?.replyToMode ?? "off"; + case "slack": + return cfg.slack?.replyToMode ?? "off"; + default: + return "all"; + } +} + +export function createReplyToModeFilter(mode: ReplyToMode) { + let hasThreaded = false; + return (payload: ReplyPayload): ReplyPayload => { + if (!payload.replyToId) return payload; + if (mode === "off") { + return { ...payload, replyToId: undefined }; + } + if (mode === "all") return payload; + if (hasThreaded) { + return { ...payload, replyToId: undefined }; + } + hasThreaded = true; + return payload; + }; +} diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 27f2bcdef..cc40383c9 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -59,6 +59,21 @@ describe("routeReply", () => { ); }); + it("passes replyToId to Telegram sends", async () => { + mocks.sendMessageTelegram.mockClear(); + await routeReply({ + payload: { text: "hi", replyToId: "123" }, + channel: "telegram", + to: "telegram:123", + cfg: {} as never, + }); + expect(mocks.sendMessageTelegram).toHaveBeenCalledWith( + "telegram:123", + "hi", + expect.objectContaining({ replyToMessageId: 123 }), + ); + }); + it("uses replyToId as threadTs for Slack", async () => { mocks.sendMessageSlack.mockClear(); await routeReply({ diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index cd8b0efdc..980b2bc0c 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -75,9 +75,16 @@ export async function routeReply( const { text, mediaUrl } = params; switch (channel) { case "telegram": { + const replyToMessageId = replyToId + ? Number.parseInt(replyToId, 10) + : undefined; + const resolvedReplyToMessageId = Number.isFinite(replyToMessageId) + ? replyToMessageId + : undefined; const result = await sendMessageTelegram(to, text, { mediaUrl, messageThreadId: threadId, + replyToMessageId: resolvedReplyToMessageId, }); return { ok: true, messageId: result.messageId }; } diff --git a/src/config/types.ts b/src/config/types.ts index 2fee23c42..cff56f3d1 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -483,6 +483,8 @@ export type SlackConfig = { 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; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 2ef882387..c8f294c90 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -1017,6 +1017,7 @@ export const ClawdbotSchema = z.object({ .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(),