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)
|
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,
|
mediaMaxMb: 5,
|
||||||
retry: { // outbound retry policy
|
retry: { // outbound retry policy
|
||||||
attempts: 3,
|
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
|
reactionNotifications: "own", // off | own | all | allowlist
|
||||||
reactionAllowlist: ["U123"],
|
reactionAllowlist: ["U123"],
|
||||||
|
replyToMode: "off", // off | first | all
|
||||||
actions: {
|
actions: {
|
||||||
reactions: true,
|
reactions: true,
|
||||||
messages: true,
|
messages: true,
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
|
|||||||
},
|
},
|
||||||
"reactionNotifications": "own",
|
"reactionNotifications": "own",
|
||||||
"reactionAllowlist": ["U123"],
|
"reactionAllowlist": ["U123"],
|
||||||
|
"replyToMode": "off",
|
||||||
"actions": {
|
"actions": {
|
||||||
"reactions": true,
|
"reactions": true,
|
||||||
"messages": true,
|
"messages": true,
|
||||||
@@ -193,6 +194,14 @@ Tokens can also be supplied via env vars:
|
|||||||
Ack reactions are controlled globally via `messages.ackReaction` +
|
Ack reactions are controlled globally via `messages.ackReaction` +
|
||||||
`messages.ackReactionScope`.
|
`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
|
## Sessions + routing
|
||||||
- DMs share the `main` session (like WhatsApp/Telegram).
|
- DMs share the `main` session (like WhatsApp/Telegram).
|
||||||
- Channels map to `slack:channel:<channelId>` sessions.
|
- Channels map to `slack:channel:<channelId>` sessions.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Telegram (Bot API)
|
# 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.
|
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.
|
- `[[reply_to:<id>]]` -- reply to a specific message id.
|
||||||
|
|
||||||
Controlled by `telegram.replyToMode`:
|
Controlled by `telegram.replyToMode`:
|
||||||
- `off` (default), `first`, `all`.
|
- `first` (default), `all`, `off`.
|
||||||
|
|
||||||
## Streaming (drafts)
|
## Streaming (drafts)
|
||||||
Telegram can stream **draft bubbles** while the agent is generating a response.
|
Telegram can stream **draft bubbles** while the agent is generating a response.
|
||||||
@@ -166,10 +166,11 @@ More context: [Streaming + chunking](/concepts/streaming).
|
|||||||
## Retry policy
|
## 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).
|
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`).
|
- Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`).
|
||||||
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
|
- 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)
|
## Delivery targets (CLI/cron)
|
||||||
- Use a chat id (`123456789`) or a username (`@name`) as the target.
|
- 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>.enabled`: disable the group when `false`.
|
||||||
- `telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
|
- `telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
|
||||||
- `telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
- `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.textChunkLimit`: outbound chunk size (chars).
|
||||||
- `telegram.streamMode`: `off | partial | block` (draft streaming).
|
- `telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||||
- `telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
@@ -225,6 +226,7 @@ Provider options:
|
|||||||
- `telegram.webhookSecret`: webhook secret (optional).
|
- `telegram.webhookSecret`: webhook secret (optional).
|
||||||
- `telegram.webhookPath`: local webhook path (default `/telegram-webhook`).
|
- `telegram.webhookPath`: local webhook path (default `/telegram-webhook`).
|
||||||
- `telegram.actions.reactions`: gate Telegram tool reactions.
|
- `telegram.actions.reactions`: gate Telegram tool reactions.
|
||||||
|
- `telegram.actions.sendMessage`: gate Telegram tool message sends.
|
||||||
|
|
||||||
Related global options:
|
Related global options:
|
||||||
- `routing.groupChat.mentionPatterns` (mention gating patterns).
|
- `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.
|
- The tool is only exposed when the current provider is WhatsApp.
|
||||||
|
|
||||||
### `telegram`
|
### `telegram`
|
||||||
Send Telegram reactions.
|
Send Telegram messages or reactions.
|
||||||
|
|
||||||
Core actions:
|
Core actions:
|
||||||
|
- `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyToMessageId`, `messageThreadId`)
|
||||||
- `react` (`chatId`, `messageId`, `emoji`, optional `remove`)
|
- `react` (`chatId`, `messageId`, `emoji`, optional `remove`)
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|||||||
@@ -226,6 +226,8 @@ export type EmbeddedPiRunResult = {
|
|||||||
// True if a messaging tool (telegram, whatsapp, discord, slack, sessions_send)
|
// True if a messaging tool (telegram, whatsapp, discord, slack, sessions_send)
|
||||||
// successfully sent a message. Used to suppress agent's confirmation text.
|
// successfully sent a message. Used to suppress agent's confirmation text.
|
||||||
didSendViaMessagingTool?: boolean;
|
didSendViaMessagingTool?: boolean;
|
||||||
|
// Texts successfully sent via messaging tools during the run.
|
||||||
|
messagingToolSentTexts?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EmbeddedPiCompactResult = {
|
export type EmbeddedPiCompactResult = {
|
||||||
@@ -1253,6 +1255,7 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
toolMetas,
|
toolMetas,
|
||||||
unsubscribe,
|
unsubscribe,
|
||||||
waitForCompactionRetry,
|
waitForCompactionRetry,
|
||||||
|
getMessagingToolSentTexts,
|
||||||
didSendViaMessagingTool,
|
didSendViaMessagingTool,
|
||||||
} = subscription;
|
} = subscription;
|
||||||
|
|
||||||
@@ -1536,6 +1539,7 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
aborted,
|
aborted,
|
||||||
},
|
},
|
||||||
didSendViaMessagingTool: didSendViaMessagingTool(),
|
didSendViaMessagingTool: didSendViaMessagingTool(),
|
||||||
|
messagingToolSentTexts: getMessagingToolSentTexts(),
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
restoreSkillEnv?.();
|
restoreSkillEnv?.();
|
||||||
|
|||||||
@@ -778,6 +778,7 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
toolMetas,
|
toolMetas,
|
||||||
unsubscribe,
|
unsubscribe,
|
||||||
isCompacting: () => compactionInFlight || pendingCompactionRetry > 0,
|
isCompacting: () => compactionInFlight || pendingCompactionRetry > 0,
|
||||||
|
getMessagingToolSentTexts: () => messagingToolSentTexts.slice(),
|
||||||
// Returns true if any messaging tool successfully sent a message.
|
// Returns true if any messaging tool successfully sent a message.
|
||||||
// Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!")
|
// Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!")
|
||||||
// which is generated AFTER the tool sends the actual answer.
|
// which is generated AFTER the tool sends the actual answer.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
queueEmbeddedPiMessage,
|
queueEmbeddedPiMessage,
|
||||||
runEmbeddedPiAgent,
|
runEmbeddedPiAgent,
|
||||||
} from "../../agents/pi-embedded.js";
|
} from "../../agents/pi-embedded.js";
|
||||||
|
import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js";
|
||||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||||
import {
|
import {
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
@@ -19,7 +20,7 @@ import { logVerbose } from "../../globals.js";
|
|||||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { stripHeartbeatToken } from "../heartbeat.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 { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
|
||||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||||
@@ -31,6 +32,10 @@ import {
|
|||||||
scheduleFollowupDrain,
|
scheduleFollowupDrain,
|
||||||
} from "./queue.js";
|
} from "./queue.js";
|
||||||
import { extractReplyToTag } from "./reply-tags.js";
|
import { extractReplyToTag } from "./reply-tags.js";
|
||||||
|
import {
|
||||||
|
createReplyToModeFilter,
|
||||||
|
resolveReplyToMode,
|
||||||
|
} from "./reply-threading.js";
|
||||||
import { incrementCompactionCount } from "./session-updates.js";
|
import { incrementCompactionCount } from "./session-updates.js";
|
||||||
import type { TypingController } from "./typing.js";
|
import type { TypingController } from "./typing.js";
|
||||||
import { createTypingSignaler } from "./typing-mode.js";
|
import { createTypingSignaler } from "./typing-mode.js";
|
||||||
@@ -147,6 +152,16 @@ export async function runReplyAgent(params: {
|
|||||||
replyToId: payload.replyToId ?? null,
|
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) {
|
if (shouldSteer && isStreaming) {
|
||||||
const steered = queueEmbeddedPiMessage(
|
const steered = queueEmbeddedPiMessage(
|
||||||
@@ -315,13 +330,12 @@ export async function runReplyAgent(params: {
|
|||||||
if (!cleaned && !hasMedia) return;
|
if (!cleaned && !hasMedia) return;
|
||||||
if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia)
|
if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia)
|
||||||
return;
|
return;
|
||||||
const blockPayload: ReplyPayload = {
|
const blockPayload: ReplyPayload = applyReplyToMode({
|
||||||
text: cleaned,
|
text: cleaned,
|
||||||
mediaUrls: payload.mediaUrls,
|
mediaUrls: payload.mediaUrls,
|
||||||
mediaUrl: payload.mediaUrls?.[0],
|
mediaUrl: payload.mediaUrls?.[0],
|
||||||
// Default to incoming message ID for threading support (replyToMode: "first"|"all")
|
replyToId: tagResult.replyToId,
|
||||||
replyToId: tagResult.replyToId ?? sessionCtx.MessageSid,
|
});
|
||||||
};
|
|
||||||
const payloadKey = buildPayloadKey(blockPayload);
|
const payloadKey = buildPayloadKey(blockPayload);
|
||||||
if (
|
if (
|
||||||
streamedPayloadKeys.has(payloadKey) ||
|
streamedPayloadKeys.has(payloadKey) ||
|
||||||
@@ -502,8 +516,7 @@ export async function runReplyAgent(params: {
|
|||||||
return {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
text: cleaned ? cleaned : undefined,
|
text: cleaned ? cleaned : undefined,
|
||||||
// Default to incoming message ID for threading support (replyToMode: "first"|"all")
|
replyToId: replyToId ?? payload.replyToId,
|
||||||
replyToId: replyToId ?? payload.replyToId ?? sessionCtx.MessageSid,
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(
|
.filter(
|
||||||
@@ -511,23 +524,31 @@ export async function runReplyAgent(params: {
|
|||||||
payload.text ||
|
payload.text ||
|
||||||
payload.mediaUrl ||
|
payload.mediaUrl ||
|
||||||
(payload.mediaUrls && payload.mediaUrls.length > 0),
|
(payload.mediaUrls && payload.mediaUrls.length > 0),
|
||||||
);
|
)
|
||||||
|
.map(applyReplyToMode);
|
||||||
|
|
||||||
// Drop final payloads if:
|
// Drop final payloads if block streaming is enabled and we already streamed
|
||||||
// 1. Block streaming is enabled and we already streamed block replies, OR
|
// block replies. Tool-sent duplicates are filtered below.
|
||||||
// 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.
|
|
||||||
const shouldDropFinalPayloads =
|
const shouldDropFinalPayloads =
|
||||||
(blockStreamingEnabled && didStreamBlockReply) ||
|
blockStreamingEnabled && didStreamBlockReply;
|
||||||
runResult.didSendViaMessagingTool === true;
|
const messagingToolSentTexts = runResult.messagingToolSentTexts ?? [];
|
||||||
|
const dedupedPayloads =
|
||||||
|
messagingToolSentTexts.length > 0
|
||||||
|
? replyTaggedPayloads.filter(
|
||||||
|
(payload) =>
|
||||||
|
!isMessagingToolDuplicate(
|
||||||
|
payload.text ?? "",
|
||||||
|
messagingToolSentTexts,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: replyTaggedPayloads;
|
||||||
const filteredPayloads = shouldDropFinalPayloads
|
const filteredPayloads = shouldDropFinalPayloads
|
||||||
? []
|
? []
|
||||||
: blockStreamingEnabled
|
: blockStreamingEnabled
|
||||||
? replyTaggedPayloads.filter(
|
? dedupedPayloads.filter(
|
||||||
(payload) => !streamedPayloadKeys.has(buildPayloadKey(payload)),
|
(payload) => !streamedPayloadKeys.has(buildPayloadKey(payload)),
|
||||||
)
|
)
|
||||||
: replyTaggedPayloads;
|
: dedupedPayloads;
|
||||||
|
|
||||||
if (filteredPayloads.length === 0) return finalizeWithFollowup(undefined);
|
if (filteredPayloads.length === 0) return finalizeWithFollowup(undefined);
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,15 @@ import { logVerbose } from "../../globals.js";
|
|||||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||||
|
import type { OriginatingChannelType } from "../templating.js";
|
||||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||||
import type { FollowupRun } from "./queue.js";
|
import type { FollowupRun } from "./queue.js";
|
||||||
import { extractReplyToTag } from "./reply-tags.js";
|
import { extractReplyToTag } from "./reply-tags.js";
|
||||||
|
import {
|
||||||
|
createReplyToModeFilter,
|
||||||
|
resolveReplyToMode,
|
||||||
|
} from "./reply-threading.js";
|
||||||
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
||||||
import { incrementCompactionCount } from "./session-updates.js";
|
import { incrementCompactionCount } from "./session-updates.js";
|
||||||
import type { TypingController } from "./typing.js";
|
import type { TypingController } from "./typing.js";
|
||||||
@@ -179,6 +184,14 @@ export function createFollowupRunner(params: {
|
|||||||
if (stripped.shouldSkip && !hasMedia) return [];
|
if (stripped.shouldSkip && !hasMedia) return [];
|
||||||
return [{ ...payload, text: stripped.text }];
|
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
|
const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads
|
||||||
.map((payload) => {
|
.map((payload) => {
|
||||||
@@ -194,7 +207,8 @@ export function createFollowupRunner(params: {
|
|||||||
payload.text ||
|
payload.text ||
|
||||||
payload.mediaUrl ||
|
payload.mediaUrl ||
|
||||||
(payload.mediaUrls && payload.mediaUrls.length > 0),
|
(payload.mediaUrls && payload.mediaUrls.length > 0),
|
||||||
);
|
)
|
||||||
|
.map(applyReplyToMode);
|
||||||
|
|
||||||
if (replyTaggedPayloads.length === 0) return;
|
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 () => {
|
it("uses replyToId as threadTs for Slack", async () => {
|
||||||
mocks.sendMessageSlack.mockClear();
|
mocks.sendMessageSlack.mockClear();
|
||||||
await routeReply({
|
await routeReply({
|
||||||
|
|||||||
@@ -75,9 +75,16 @@ export async function routeReply(
|
|||||||
const { text, mediaUrl } = params;
|
const { text, mediaUrl } = params;
|
||||||
switch (channel) {
|
switch (channel) {
|
||||||
case "telegram": {
|
case "telegram": {
|
||||||
|
const replyToMessageId = replyToId
|
||||||
|
? Number.parseInt(replyToId, 10)
|
||||||
|
: undefined;
|
||||||
|
const resolvedReplyToMessageId = Number.isFinite(replyToMessageId)
|
||||||
|
? replyToMessageId
|
||||||
|
: undefined;
|
||||||
const result = await sendMessageTelegram(to, text, {
|
const result = await sendMessageTelegram(to, text, {
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
messageThreadId: threadId,
|
messageThreadId: threadId,
|
||||||
|
replyToMessageId: resolvedReplyToMessageId,
|
||||||
});
|
});
|
||||||
return { ok: true, messageId: result.messageId };
|
return { ok: true, messageId: result.messageId };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -483,6 +483,8 @@ export type SlackConfig = {
|
|||||||
reactionNotifications?: SlackReactionNotificationMode;
|
reactionNotifications?: SlackReactionNotificationMode;
|
||||||
/** Allowlist for reaction notifications when mode is allowlist. */
|
/** Allowlist for reaction notifications when mode is allowlist. */
|
||||||
reactionAllowlist?: Array<string | number>;
|
reactionAllowlist?: Array<string | number>;
|
||||||
|
/** Control reply threading when reply tags are present (off|first|all). */
|
||||||
|
replyToMode?: ReplyToMode;
|
||||||
actions?: SlackActionConfig;
|
actions?: SlackActionConfig;
|
||||||
slashCommand?: SlackSlashCommandConfig;
|
slashCommand?: SlackSlashCommandConfig;
|
||||||
dm?: SlackDmConfig;
|
dm?: SlackDmConfig;
|
||||||
|
|||||||
@@ -1017,6 +1017,7 @@ export const ClawdbotSchema = z.object({
|
|||||||
.enum(["off", "own", "all", "allowlist"])
|
.enum(["off", "own", "all", "allowlist"])
|
||||||
.optional(),
|
.optional(),
|
||||||
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
|
replyToMode: ReplyToModeSchema.optional(),
|
||||||
actions: z
|
actions: z
|
||||||
.object({
|
.object({
|
||||||
reactions: z.boolean().optional(),
|
reactions: z.boolean().optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user