fix: polish reply threading + tool dedupe (thanks @mneves75) (#326)

This commit is contained in:
Peter Steinberger
2026-01-08 00:50:29 +00:00
parent 33e2d53be3
commit 17d052bcda
16 changed files with 193 additions and 350 deletions

View File

@@ -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<string, string>();
```
**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<string, number> = {};
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*

View File

@@ -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,

View File

@@ -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:<id>]]` — 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:<channelId>` sessions.

View File

@@ -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:<id>]]` -- 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.<id>.enabled`: disable the group when `false`.
- `telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
- `telegram.groups.<id>.topics.<threadId>.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).

View File

@@ -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:<chat_id>:topic:<topic_id>
```
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.

View File

@@ -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:

View File

@@ -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?.();

View File

@@ -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.

View File

@@ -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);

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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;
};
}

View File

@@ -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({

View File

@@ -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 };
}

View File

@@ -483,6 +483,8 @@ export type SlackConfig = {
reactionNotifications?: SlackReactionNotificationMode;
/** Allowlist for reaction notifications when mode is allowlist. */
reactionAllowlist?: Array<string | number>;
/** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode;
actions?: SlackActionConfig;
slashCommand?: SlackSlashCommandConfig;
dm?: SlackDmConfig;

View File

@@ -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(),