mirror of
https://github.com/clawdbot/clawdbot.git
synced 2026-01-31 19:37:45 +01:00
fix: polish reply threading + tool dedupe (thanks @mneves75) (#326)
This commit is contained in:
195
PR-326-REVIEW.md
195
PR-326-REVIEW.md
@@ -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*
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
130
docs/telegram.md
130
docs/telegram.md
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
53
src/auto-reply/reply/reply-threading.test.ts
Normal file
53
src/auto-reply/reply/reply-threading.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
36
src/auto-reply/reply/reply-threading.ts
Normal file
36
src/auto-reply/reply/reply-threading.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user