diff --git a/AGENTS.md b/AGENTS.md index 2beff581fb53..848f70bca543 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,6 +119,13 @@ Skills own workflows; root owns hard policy and routing. - Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs ` for tiny explicit-file proof, or Crabbox/Testbox for anything broader. - Checks in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`. - Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox, not locally. +- Direct Testbox runs: pass `--provider blacksmith-testbox`; `OPENCLAW_TESTBOX=1` only selects `scripts/pr` prepare behavior. +- Release-branch Testbox warmup: `crabbox-wrapper.mjs warmup --provider blacksmith-testbox --blacksmith-ref `; never `--ref`/raw SHA/default `main` (mixed-base dependency state). +- Multi-command Testbox run: use `--shell -- "cmd1 && cmd2"`; unquoted `&&` escapes the wrapper and runs later commands locally. +- Fresh Testbox pnpm runs: prefix command with `env CI=1`; non-TTY dependency reconciliation otherwise aborts. +- Explicit test paths: verify with `rg --files` first; one missing path aborts the whole Testbox shard. +- Interrupted Crabbox run: verify/terminate remote child + heavy-check lock; local Ctrl-C may leave remote pnpm alive. +- Crabbox wrapper stop: `node scripts/crabbox-wrapper.mjs stop --provider --id `; no positional id or `--timing-json`. - Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/`. - Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`. - Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`). @@ -131,6 +138,8 @@ Skills own workflows; root owns hard policy and routing. - Visual proof: use Crabbox, set up like a user, then screenshot-verify. No harness/bypass/shortcut unless explicitly asked. - Small/narrow tests, lints, format checks, and type probes are fine locally only in a healthy normal checkout. - In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof. +- Codex-worktree commit after equivalent remote hook proof: `git commit --no-verify --no-gpg-sign`; do not invoke `scripts/committer`. +- Git continuation commands run commit hooks too; pre-format and use hook-free continuation only after equivalent remote proof. - Full suites, broad changed gates, Docker/package/E2E/live/cross-OS proof, or anything that bogs down the Mac: Crabbox/Testbox. - One/few files local. If a local command fans out, stop and move broad proof to Crabbox/Testbox. - Before handoff/push: prove touched surface. Before landing to `main`: issue proof plus appropriate full/broad proof unless scope is clearly narrow. @@ -233,6 +242,8 @@ Skills own workflows; root owns hard policy and routing. ## Git +- zsh: quote optional glob patterns; unmatched globs abort commands. +- LTS worktrees: Testbox full sync can mix main hydration with release packages; use direct Crabbox when lock/package shapes differ. - Commit via `scripts/committer "" `; stage intended files only. - Commits: conventional-ish, concise, grouped. - No manual stash/autostash unless explicit. Branch switches ok when useful; no new worktrees unless requested. diff --git a/docs/channels/bot-loop-protection.md b/docs/channels/bot-loop-protection.md index e05fee4118ac..96c88e47b948 100644 --- a/docs/channels/bot-loop-protection.md +++ b/docs/channels/bot-loop-protection.md @@ -9,9 +9,9 @@ sidebarTitle: "Bot loop protection" # Bot loop protection -OpenClaw can accept messages written by other bots on channels that support `allowBots`. -When that path is enabled, pair loop protection prevents two bot identities from -replying to each other indefinitely. +OpenClaw can accept messages written by other bots through channel-native bot policy or +an `allowBots` option. When that path is enabled, pair loop protection prevents two bot +identities from replying to each other indefinitely. The guard is enforced by the core inbound reply runner. Each supporting channel maps its own inbound event into generic facts: account or scope, conversation id, @@ -122,6 +122,7 @@ Supporting channels layer their own config over the shared default. Precedence i - Slack: native `bot_id` facts for accepted bot-authored messages, keyed by Slack account, channel, and bot pair. - Matrix: configured Matrix bot accounts, keyed by Matrix account, room, and configured bot pair. - Google Chat: native `sender.type=BOT` facts for accepted bot-authored messages, keyed by account, space, and bot pair. +- Telegram: native `from.is_bot` facts for accepted bot-authored messages, keyed by Telegram account, chat/topic, and bot pair. Telegram currently uses the shared `channels.defaults.botLoopProtection` policy. Channels that do not expose a reliable inbound bot identity keep using their normal self-message and access-policy filters. They should not opt into this diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 69f4cf492687..27fdb06e4bb7 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -295,10 +295,15 @@ curl "https://api.telegram.org/bot/getUpdates" - DM messages can carry `message_thread_id`; OpenClaw preserves it for replies. DM topic sessions split only when Telegram `getMe` reports `has_topics_enabled: true` for the bot; otherwise DMs stay on the flat session. - Long polling uses grammY runner with per-chat/per-thread sequencing. Overall runner sink concurrency uses `agents.defaults.maxConcurrent`. - Multi-account startup bounds concurrent Telegram `getMe` probes so large bot fleets do not fan out every account probe at once. +- Accepted bot-authored messages use shared [bot loop protection](/channels/bot-loop-protection), including combined media albums and long-message fragments. Configure `channels.defaults.botLoopProtection`; set `enabled: false` there only when you intentionally allow unrestricted bot-to-bot conversations. - Long polling is guarded inside each gateway process so only one active poller can use a bot token at a time. If you still see `getUpdates` 409 conflicts, another OpenClaw gateway, script, or external poller is likely using the same token. - Long-polling watchdog restarts trigger after 120 seconds without completed `getUpdates` liveness by default. Increase `channels.telegram.pollingStallThresholdMs` only if your deployment still sees false polling-stall restarts during long-running work. The value is in milliseconds and is allowed from `30000` to `600000`; per-account overrides are supported. - Telegram Bot API has no read-receipt support (`sendReadReceipts` does not apply). + + Bot loop protection does not enable Telegram delivery. Enable **Bot-to-Bot Communication Mode** for the OpenClaw bot in BotFather. In groups, an explicit command mention or reply works when at least one participating bot enables the mode; ambient bot messages additionally require the receiving bot to enable it and either be a group admin or have Group Privacy Mode disabled. Private bot-to-bot chats require the mode on both bots. For accepted bot-authored turns, OpenClaw defaults to explicit reply targeting so every standard-message fragment remains observable; an explicit `replyToMode: "off"` disables this peer-bot exception. See [Telegram's bot-to-bot communication rules](https://core.telegram.org/bots/features#bot-to-bot-communication). + + `channels.telegram.dm.threadReplies` and `channels.telegram.direct..threadReplies` were removed. Run `openclaw doctor --fix` after upgrading if your config still has those keys. DM topic routing now follows the bot capability from Telegram `getMe.has_topics_enabled`, which is controlled by BotFather threaded mode: topics-enabled bots use thread-scoped DM sessions when Telegram sends `message_thread_id`; other DMs stay on the flat session. diff --git a/extensions/telegram/src/action-runtime.test.ts b/extensions/telegram/src/action-runtime.test.ts index a6189245f3ee..dea64623694d 100644 --- a/extensions/telegram/src/action-runtime.test.ts +++ b/extensions/telegram/src/action-runtime.test.ts @@ -2,12 +2,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { DurableMessageBatchSendResult } from "openclaw/plugin-sdk/channel-outbound"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { captureEnv } from "openclaw/plugin-sdk/test-env"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { handleTelegramAction, telegramActionRuntime } from "./action-runtime.js"; import { beginTelegramInboundEventDeliveryCorrelation } from "./inbound-event-delivery.js"; +import { runWithTelegramPeerBotTurn } from "./peer-bot-turn.js"; import { getTopicName, resetTopicNameCacheForTest, @@ -32,11 +34,14 @@ const sendDurableMessageBatch = vi.fn( text?: string; mediaUrl?: string; mediaUrls?: string[]; + replyToId?: string; audioAsVoice?: boolean; delivery?: { pin?: true | { enabled?: boolean; notify?: boolean; required?: boolean }; }; - channelData?: { telegram?: { buttons?: unknown; quoteText?: string } }; + channelData?: { + telegram?: { buttons?: unknown; quoteText?: string; standardMessage?: boolean }; + }; }>; replyToId?: string; threadId?: string | number; @@ -74,16 +79,17 @@ const sendDurableMessageBatch = vi.fn( : undefined) ?? cfg.channels?.telegram?.botToken ?? process.env.TELEGRAM_BOT_TOKEN; + const replyToId = payload.replyToId ?? params.replyToId; const baseOptions = { cfg: params.cfg, token, accountId: params.accountId, gatewayClientScopes: params.gatewayClientScopes, - replyToMessageId: - params.replyToId == null ? undefined : Number.parseInt(params.replyToId, 10), + replyToMessageId: replyToId == null ? undefined : Number.parseInt(replyToId, 10), messageThreadId: params.threadId == null ? undefined : Number.parseInt(String(params.threadId), 10), quoteText: telegramData?.quoteText, + standardMessage: telegramData?.standardMessage, asVoice: payload.audioAsVoice, silent: params.silent, forceDocument: params.forceDocument, @@ -644,6 +650,389 @@ describe("handleTelegramAction", () => { }); }); + it("keeps an explicit peer-bot DM private while using standard delivery", async () => { + await runWithTelegramPeerBotTurn( + { + accountId: "default", + chatId: "-123", + messageId: 456, + senderAliases: ["@peer_bot"], + senderId: "7654321", + }, + async () => + await handleTelegramAction( + { + action: "sendMessage", + to: "@peer_bot", + content: "Visible to the peer bot", + }, + telegramConfig(), + { sessionKey: "agent:main:telegram:group:-123" }, + ), + ); + + const durableCall = mockCall(sendDurableMessageBatch, 0, "peer-bot durable message"); + expect(requireRecord(durableCall[0], "peer-bot durable message params")).toMatchObject({ + to: "7654321", + payloads: [ + { + text: "Visible to the peer bot", + channelData: { telegram: { standardMessage: true } }, + }, + ], + }); + expect( + requireRecord(durableCall[0], "peer-bot durable message params").replyToId, + ).toBeUndefined(); + expect(mockCall(sendMessageTelegram, 0, "peer-bot message")).toMatchObject([ + "7654321", + "Visible to the peer bot", + { replyToMessageId: undefined, standardMessage: true }, + ]); + }); + + it("recognizes the peer bot's canonical numeric sender target", async () => { + await runWithTelegramPeerBotTurn( + { + accountId: "default", + chatId: "-123", + messageId: 456, + senderAliases: ["@peer_bot"], + senderId: "7654321", + }, + async () => + await handleTelegramAction( + { action: "sendMessage", to: "7654321", content: "Numeric private reply" }, + telegramConfig(), + ), + ); + + expect(mockCall(sendMessageTelegram, 0, "numeric peer target")).toMatchObject([ + "7654321", + "Numeric private reply", + { replyToMessageId: undefined, standardMessage: true }, + ]); + }); + + it("does not equate distinct short bot usernames when generic lookup normalization fails", async () => { + await runWithTelegramPeerBotTurn( + { + accountId: "default", + chatId: "-123", + messageId: 456, + senderAliases: ["@gif"], + senderId: "7654321", + }, + async () => + await handleTelegramAction( + { action: "sendMessage", to: "@wiki", content: "Different short bot" }, + telegramConfig(), + ), + ); + + expect(mockCall(sendMessageTelegram, 0, "distinct short bot target")).toMatchObject([ + "@wiki", + "Different short bot", + { standardMessage: undefined }, + ]); + }); + + it("canonicalizes a source-group alias for same-chat peer delivery", async () => { + await runWithTelegramPeerBotTurn( + { + accountId: "default", + chatAliases: ["@source_group"], + chatId: "-123", + messageId: 456, + senderAliases: ["@peer_bot"], + senderId: "7654321", + }, + async () => + await handleTelegramAction( + { action: "sendMessage", to: "@source_group", content: "Group reply" }, + telegramConfig(), + ), + ); + + expect(mockCall(sendMessageTelegram, 0, "peer source-group message")).toMatchObject([ + "-123", + "Group reply", + { replyToMessageId: 456, standardMessage: true }, + ]); + expect( + requireRecord( + mockCall(sendDurableMessageBatch, 0, "peer source-group durable message")[0], + "peer source-group durable params", + ), + ).toMatchObject({ replyToId: "456", replyToMode: "all" }); + }); + + it("preserves explicit replyToMode off for same-chat peer delivery", async () => { + await runWithTelegramPeerBotTurn( + { + accountId: "default", + chatId: "-123", + messageId: 456, + senderId: "7654321", + }, + async () => + await handleTelegramAction( + { action: "sendMessage", to: "-123", content: "Unthreaded group reply" }, + telegramConfig({ replyToMode: "off" }), + ), + ); + + expect(mockCall(sendMessageTelegram, 0, "unthreaded peer source-group message")).toMatchObject([ + "-123", + "Unthreaded group reply", + { replyToMessageId: undefined, standardMessage: true }, + ]); + }); + + it("preserves explicit peer reply targets when replyToMode is off", async () => { + await runWithTelegramPeerBotTurn( + { + accountId: "default", + chatId: "-123", + messageId: 456, + senderId: "7654321", + }, + async () => + await handleTelegramAction( + { + action: "sendMessage", + to: "-123", + content: "Explicit group reply", + replyToMessageId: 999, + }, + telegramConfig({ replyToMode: "off" }), + ), + ); + + const durableParams = requireRecord( + mockCall(sendDurableMessageBatch, 0, "explicit peer reply")[0], + "explicit peer reply params", + ); + expect(durableParams.payloads).toEqual([ + expect.objectContaining({ text: "Explicit group reply", replyToId: "999" }), + ]); + expect(durableParams.replyToId).toBeUndefined(); + }); + + it("inherits the source topic when a peer-bot DM uses its username alias", async () => { + await runWithTelegramPeerBotTurn( + { + accountId: "default", + chatAliases: ["@peer_bot"], + chatId: "123", + messageId: 456, + senderId: "123", + threadId: 7, + }, + async () => + await handleTelegramAction( + { action: "sendMessage", to: "@peer_bot", content: "Topic reply" }, + telegramConfig(), + { sessionKey: "agent:main:telegram:direct:123:topic:7" }, + ), + ); + + expect(mockCall(sendMessageTelegram, 0, "peer topic message")).toMatchObject([ + "123", + "Topic reply", + { messageThreadId: 7, replyToMessageId: 456, standardMessage: true }, + ]); + }); + + it.each(["peer_bot", "https://t.me/peer_bot"])( + "normalizes peer-bot username target %s before alias matching", + async (to) => { + await runWithTelegramPeerBotTurn( + { + accountId: "default", + chatAliases: ["@peer_bot"], + chatId: "123", + messageId: 456, + senderId: "123", + }, + async () => + await handleTelegramAction( + { action: "sendMessage", to, content: "Normalized alias reply" }, + telegramConfig(), + ), + ); + + expect(mockCall(sendMessageTelegram, 0, "normalized peer alias")).toMatchObject([ + "123", + "Normalized alias reply", + { replyToMessageId: 456, standardMessage: true }, + ]); + }, + ); + + it("does not canonicalize a peer alias for a different Telegram account", async () => { + await runWithTelegramPeerBotTurn( + { + accountId: "default", + chatId: "-123", + messageId: 456, + senderAliases: ["@peer_bot"], + senderId: "7654321", + }, + async () => + await handleTelegramAction( + { + accountId: "other", + action: "sendMessage", + to: "@peer_bot", + content: "Separate account message", + }, + telegramConfig({ accounts: { other: { botToken: "other-tok" } } }), + ), + ); + + expect(mockCall(sendMessageTelegram, 0, "cross-account peer message")).toMatchObject([ + "@peer_bot", + "Separate account message", + { accountId: "other", replyToMessageId: undefined, standardMessage: undefined }, + ]); + }); + + it("matches peer turns through normalized Telegram account IDs", async () => { + await runWithTelegramPeerBotTurn( + { + accountId: "work", + chatId: "-123", + messageId: 456, + senderAliases: ["@peer_bot"], + senderId: "7654321", + }, + async () => + await handleTelegramAction( + { + accountId: "Work", + action: "sendMessage", + to: "@peer_bot", + content: "Normalized account reply", + }, + telegramConfig({ accounts: { work: { botToken: "work-tok" } } }), + ), + ); + + expect(mockCall(sendMessageTelegram, 0, "normalized account peer message")).toMatchObject([ + "7654321", + "Normalized account reply", + { accountId: "Work", standardMessage: true }, + ]); + }); + + it("validates peer aliases using the canonical chat for scoped inline buttons", async () => { + await runWithTelegramPeerBotTurn( + { + accountId: "default", + chatAliases: ["@peer_bot"], + chatId: "123", + messageId: 456, + senderId: "123", + }, + async () => + await handleTelegramAction( + { + action: "sendMessage", + to: "@peer_bot", + content: "Choose", + presentation: { + blocks: [{ type: "buttons", buttons: [{ label: "Ok", value: "cmd:ok" }] }], + }, + }, + telegramConfig({ capabilities: { inlineButtons: "dm" } }), + ), + ); + + expect(mockCall(sendMessageTelegram, 0, "peer inline buttons")[0]).toBe("123"); + }); + + it("allows DM-scoped buttons for an explicit peer-bot DM from a group turn", async () => { + await runWithTelegramPeerBotTurn( + { + accountId: "default", + chatId: "-123", + messageId: 456, + senderAliases: ["@peer_bot"], + senderId: "7654321", + }, + async () => + await handleTelegramAction( + { + action: "sendMessage", + to: "@peer_bot", + content: "Choose privately", + presentation: { + blocks: [{ type: "buttons", buttons: [{ label: "Ok", value: "cmd:ok" }] }], + }, + }, + telegramConfig({ capabilities: { inlineButtons: "dm" } }), + ), + ); + + expect(mockCall(sendMessageTelegram, 0, "peer private buttons")).toMatchObject([ + "7654321", + "Choose privately", + { replyToMessageId: undefined, standardMessage: true }, + ]); + }); + + it("does not apply peer-bot delivery to another Telegram topic", async () => { + await runWithTelegramPeerBotTurn( + { + accountId: "default", + chatId: "-123", + messageId: 456, + senderId: "7654321", + threadId: 7, + }, + async () => + await handleTelegramAction( + { + action: "sendMessage", + to: "-123:topic:8", + content: "Different topic", + }, + telegramConfig(), + { sessionKey: "agent:main:telegram:group:-123:topic:7" }, + ), + ); + + const durableCall = requireRecord( + mockCall(sendDurableMessageBatch, 0, "cross-topic message")[0], + "cross-topic message params", + ); + expect(durableCall.replyToId).toBeUndefined(); + expect(durableCall.payloads).toEqual([{ text: "Different topic" }]); + }); + + it("preserves peer-bot delivery for a queued follow-up", async () => { + const sessionKey = "agent:main:telegram:group:-123"; + await runWithTelegramPeerBotTurn( + { accountId: "default", chatId: "-123", messageId: 456, senderId: "7654321" }, + async () => + await handleTelegramAction( + { action: "sendMessage", to: "-123", content: "Queued reply" }, + telegramConfig(), + { sessionKey }, + ), + ); + + const durableCall = requireRecord( + mockCall(sendDurableMessageBatch, 0, "queued peer-bot message")[0], + "queued peer-bot message params", + ); + expect(durableCall).toMatchObject({ + replyToId: "456", + payloads: [{ text: "Queued reply", channelData: { telegram: { standardMessage: true } } }], + }); + }); + it("persists sendMessage action deliveries before Telegram platform send", async () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-action-durable-")); const { @@ -814,6 +1203,44 @@ describe("handleTelegramAction", () => { end(); }); + it("marks the matching inbound event delivered after a partial send", async () => { + const partialError = new Error("second chunk failed"); + const partialResult: DurableMessageBatchSendResult = { + status: "partial_failed", + results: [{ channel: "telegram", messageId: "789", chatId: "123" }], + receipt: { + primaryPlatformMessageId: "789", + platformMessageIds: ["789"], + parts: [{ platformMessageId: "789", kind: "text", index: 0 }], + sentAt: 1, + }, + error: partialError, + sentBeforeError: true, + }; + telegramActionRuntime.sendDurableMessageBatch = vi.fn(async () => partialResult); + let count = 0; + const end = beginTelegramInboundEventDeliveryCorrelation("telegram-session", { + outboundTo: "@testchannel", + markInboundEventDelivered: () => { + count += 1; + }, + }); + + await expect( + handleTelegramAction( + { + action: "sendMessage", + to: "@testchannel", + content: "Hello, Telegram!", + }, + telegramConfig(), + { sessionKey: "telegram-session" }, + ), + ).rejects.toBe(partialError); + expect(count).toBe(1); + end(); + }); + it("marks room-event delivery correlations separately", async () => { let roomEventCount = 0; let userRequestCount = 0; diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts index 215908797c23..ec8232676139 100644 --- a/extensions/telegram/src/action-runtime.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -23,9 +23,11 @@ import { } from "openclaw/plugin-sdk/interactive-runtime"; import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { createTelegramActionGate, + mergeTelegramAccountConfig, resolveDefaultTelegramAccountId, resolveTelegramPollActionGateState, } from "./accounts.js"; @@ -36,6 +38,7 @@ import { resolveTelegramTargetChatType, } from "./inline-buttons.js"; import { resolveTelegramInteractiveTextFallback } from "./interactive-fallback.js"; +import { getTelegramPeerBotTurn } from "./peer-bot-turn.js"; import { resolveTelegramPollVisibility } from "./poll-visibility.js"; import { resolveTelegramReactionLevel } from "./reaction-level.js"; import { @@ -51,7 +54,11 @@ import { sendStickerTelegram, } from "./send.js"; import { getCacheStats, searchStickers } from "./sticker-cache.js"; -import { normalizeTelegramOutboundTarget, parseTelegramTarget } from "./targets.js"; +import { + normalizeTelegramLookupTarget, + normalizeTelegramOutboundTarget, + parseTelegramTarget, +} from "./targets.js"; import { resolveTelegramToken } from "./token.js"; import { resolveTopicNameCacheScope, updateTopicName } from "./topic-name-cache.js"; @@ -284,12 +291,14 @@ function buildTelegramActionSendPayload(params: { pin?: ReturnType; buttons?: ReturnType; quoteText?: string; + standardMessage?: boolean; }): ReplyPayload { const telegramData = - params.buttons || params.quoteText + params.buttons || params.quoteText || params.standardMessage ? { ...(params.buttons ? { buttons: params.buttons } : {}), ...(params.quoteText ? { quoteText: params.quoteText } : {}), + ...(params.standardMessage ? { standardMessage: true } : {}), } : undefined; return { @@ -440,6 +449,48 @@ export async function handleTelegramAction( throw new Error("Telegram sendMessage is disabled."); } const to = normalizeTelegramOutboundTarget(readStringParam(params, "to", { required: true })); + const peerBotTurn = getTelegramPeerBotTurn(); + const resolvedAccountId = normalizeAccountId(accountId ?? resolveDefaultTelegramAccountId(cfg)); + const peerBotReplyToMode = + mergeTelegramAccountConfig(cfg, resolvedAccountId)?.replyToMode ?? "all"; + const peerAccountMatches = + peerBotTurn !== undefined && normalizeAccountId(peerBotTurn.accountId) === resolvedAccountId; + const parsedTarget = parseTelegramTarget(to); + const peerAliasKey = (raw: string): string | undefined => { + const normalized = normalizeTelegramLookupTarget(raw); + if (normalized) { + return normalized.toLowerCase(); + } + const direct = raw.trim().match(/^@?([a-z0-9_]+)$/i); + const telegramUrl = raw.trim().match(/^(?:https?:\/\/)?t\.me\/([a-z0-9_]+)\/?$/i); + const username = direct?.[1] ?? telegramUrl?.[1]; + return username ? `@${username.toLowerCase()}` : undefined; + }; + const normalizedPeerLookupTarget = peerAliasKey(parsedTarget.chatId); + const matchesPeerAlias = (aliases?: string[]) => + normalizedPeerLookupTarget !== undefined && + aliases?.some((alias) => peerAliasKey(alias) === normalizedPeerLookupTarget) === true; + const peerChatAliasMatches = peerAccountMatches && matchesPeerAlias(peerBotTurn?.chatAliases); + const peerSenderAliasMatches = + peerAccountMatches && matchesPeerAlias(peerBotTurn?.senderAliases); + const peerSenderIdMatches = peerAccountMatches && parsedTarget.chatId === peerBotTurn?.senderId; + const targetMatchesPeerSender = peerSenderAliasMatches || peerSenderIdMatches; + const targetMatchesSourceChat = + peerAccountMatches && + peerBotTurn !== undefined && + (parsedTarget.chatId === peerBotTurn.chatId || peerChatAliasMatches); + // Chat aliases identify the source turn. Sender aliases resolve to the known + // numeric sender id so private bot delivery never depends on getChat(username). + const deliveryTarget = peerChatAliasMatches + ? peerBotTurn?.chatId + : peerSenderAliasMatches + ? peerBotTurn?.senderId + : to; + const messageThreadId = readTelegramThreadId(params); + const effectiveMessageThreadId = + messageThreadId ?? + parsedTarget.messageThreadId ?? + (targetMatchesSourceChat ? peerBotTurn?.threadId : undefined); const mediaUrls = readTelegramSendMediaUrls(params); const firstMediaUrl = mediaUrls[0]; const presentation = normalizeMessagePresentation(params.presentation); @@ -462,7 +513,9 @@ export async function handleTelegramAction( ); } if (inlineButtonsScope === "dm" || inlineButtonsScope === "group") { - const targetType = resolveTelegramTargetChatType(to); + const targetType = targetMatchesPeerSender + ? "direct" + : resolveTelegramTargetChatType(deliveryTarget); if (targetType === "unknown") { throw new Error( `Telegram inline buttons require a numeric chat id when inlineButtons="${inlineButtonsScope}".`, @@ -479,8 +532,17 @@ export async function handleTelegramAction( } } // Optional threading parameters for forum topics and reply chains - const replyToMessageId = readTelegramReplyToMessageId(params); - const messageThreadId = readTelegramThreadId(params); + const explicitReplyToMessageId = readTelegramReplyToMessageId(params); + const peerBotSourceReplyTarget = + peerBotTurn !== undefined && + targetMatchesSourceChat && + effectiveMessageThreadId === peerBotTurn.threadId; + const peerBotStandardTarget = peerBotSourceReplyTarget || targetMatchesPeerSender; + const replyToMessageId = + explicitReplyToMessageId ?? + (peerBotSourceReplyTarget && peerBotReplyToMode !== "off" + ? peerBotTurn.messageId + : undefined); const quoteText = readStringParam(params, "quoteText"); const token = resolveTelegramToken(cfg, { accountId }).token; if (!token) { @@ -493,7 +555,7 @@ export async function handleTelegramAction( accountId: accountId ?? undefined, gatewayClientScopes: options?.gatewayClientScopes, replyToMessageId: replyToMessageId ?? undefined, - messageThreadId: messageThreadId ?? undefined, + messageThreadId: effectiveMessageThreadId, quoteText: quoteText ?? undefined, asVoice: readBooleanParam(params, "asVoice"), silent: readBooleanParam(params, "silent"), @@ -502,14 +564,19 @@ export async function handleTelegramAction( readBooleanParam(params, "asDocument") ?? false, }; - const payload = buildTelegramActionSendPayload({ + const basePayload = buildTelegramActionSendPayload({ content, mediaUrls, asVoice: sendOptions.asVoice, pin: normalizeTelegramDeliveryPin(params), buttons, quoteText, + standardMessage: peerBotStandardTarget, }); + const payload = + explicitReplyToMessageId == null + ? basePayload + : { ...basePayload, replyToId: String(explicitReplyToMessageId) }; const mediaAccess = options?.mediaLocalRoots || options?.mediaReadFile ? { @@ -525,11 +592,15 @@ export async function handleTelegramAction( const durableResult = await telegramActionRuntime.sendDurableMessageBatch({ cfg, channel: "telegram", - to, + to: deliveryTarget, accountId: accountId ?? undefined, payloads: [payload], - replyToId: replyToMessageId == null ? undefined : String(replyToMessageId), - threadId: messageThreadId, + replyToId: + explicitReplyToMessageId == null && replyToMessageId != null + ? String(replyToMessageId) + : undefined, + replyToMode: peerBotStandardTarget ? peerBotReplyToMode : undefined, + threadId: effectiveMessageThreadId, forceDocument: sendOptions.forceDocument, silent: sendOptions.silent, durability: "required", @@ -537,14 +608,18 @@ export async function handleTelegramAction( ...(mediaAccess ? { mediaAccess } : {}), ...(outboundSession ? { session: outboundSession } : {}), }); - if (durableResult.status === "failed" || durableResult.status === "partial_failed") { + if (durableResult.status === "partial_failed") { + notifyVisibleOutboundSuccess(deliveryTarget, effectiveMessageThreadId); + throw durableResult.error; + } + if (durableResult.status === "failed") { throw durableResult.error; } if (durableResult.status === "suppressed") { throw new Error("Telegram sendMessage was suppressed before delivery."); } const result = getLastDurableTelegramActionResult(durableResult); - notifyVisibleOutboundSuccess(to, messageThreadId); + notifyVisibleOutboundSuccess(deliveryTarget, effectiveMessageThreadId); return jsonResult({ ok: true, messageId: result.messageId, diff --git a/extensions/telegram/src/bot-core.ts b/extensions/telegram/src/bot-core.ts index e6f9e0ca270b..5be10f0f116e 100644 --- a/extensions/telegram/src/bot-core.ts +++ b/extensions/telegram/src/bot-core.ts @@ -49,6 +49,7 @@ import { resolveTelegramOutboundClientTimeoutFloorSeconds, } from "./client-fetch.js"; import { resolveTelegramTransport } from "./fetch.js"; +import { createTelegramPeerBotAdmissionCoordinator } from "./peer-bot-admission.js"; import { stringifyTelegramRawUpdateForLog } from "./raw-update-log.js"; import { TELEGRAM_RICH_TEXT_LIMIT } from "./rich-message.js"; import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js"; @@ -418,6 +419,7 @@ export function createTelegramBotCore( opts, telegramDeps, }); + const peerBotAdmission = createTelegramPeerBotAdmissionCoordinator(); registerTelegramNativeCommands({ bot, @@ -439,6 +441,7 @@ export function createTelegramBotCore( shouldSkipUpdate, opts, telegramDeps, + peerBotAdmission, }); registerTelegramHandlers({ @@ -460,6 +463,7 @@ export function createTelegramBotCore( processMessage, logger, telegramDeps, + peerBotAdmission, }); const originalStop = bot.stop.bind(bot); diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index a7170ec697e2..89682d334232 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -32,8 +32,11 @@ import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime"; import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime"; import { formatModelsAvailableHeader } from "openclaw/plugin-sdk/models-provider-runtime"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; -import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; -import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; +import { + normalizeAccountId, + resolveAgentRoute, + resolveThreadSessionKeys, +} from "openclaw/plugin-sdk/routing"; import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; import { loadSessionStore, @@ -42,7 +45,7 @@ import { } from "openclaw/plugin-sdk/session-store-runtime"; import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js"; -import { resolveTelegramMediaRuntimeOptions } from "./accounts.js"; +import { resolveDefaultTelegramAccountId, resolveTelegramMediaRuntimeOptions } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { normalizeDmAllowFromWithStore, @@ -109,6 +112,11 @@ import { resolveTelegramConversationBaseSessionKey, resolveTelegramConversationRoute, } from "./conversation-route.js"; +import { + combineTelegramDeferredAdmissionCallbacks, + settleTelegramDeferredAdmissionCallbacks, + type TelegramDeferredAdmissionCallback, +} from "./deferred-admission.js"; import { enforceTelegramDmAccess, isTelegramDmAccessAllowed } from "./dm-access.js"; import { resolveTelegramExecApproval } from "./exec-approval-resolver.js"; import { @@ -157,7 +165,19 @@ import { isTelegramEditTargetMissingError, isTelegramMessageHasNoTextError, } from "./network-errors.js"; +import { + buildTelegramPeerBotAdmissionKey, + createTelegramPeerBotAdmissionCoordinator, +} from "./peer-bot-admission.js"; +import { shouldSuppressTelegramPeerBotTurn } from "./peer-bot-loop.js"; import { buildInlineKeyboard } from "./send.js"; +import { + resolveTelegramStandardFragmentFrame, + stripTelegramStandardFragmentMarker, + TELEGRAM_STANDARD_FRAGMENT_ADMISSION_FLOOR, + TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS, + TELEGRAM_STANDARD_FRAGMENT_MAX_WIRE_CHARS, +} from "./standard-text.js"; export const registerTelegramHandlers = ({ cfg, @@ -176,6 +196,7 @@ export const registerTelegramHandlers = ({ processMessage, logger, telegramDeps, + peerBotAdmission = createTelegramPeerBotAdmissionCoordinator(), resolveGroupActivation, resolveGroupRequireMention, }: RegisterTelegramHandlerParams) => { @@ -186,15 +207,24 @@ export const registerTelegramHandlers = ({ transport: telegramTransport, }); const DEFAULT_TEXT_FRAGMENT_MAX_GAP_MS = 1500; - const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000; + const DEFAULT_PEER_BOT_TEXT_FRAGMENT_MAX_GAP_MS = 120_000; + const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = TELEGRAM_STANDARD_FRAGMENT_ADMISSION_FLOOR; const TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS = typeof opts.testTimings?.textFragmentGapMs === "number" && Number.isFinite(opts.testTimings.textFragmentGapMs) ? Math.max(10, Math.floor(opts.testTimings.textFragmentGapMs)) : DEFAULT_TEXT_FRAGMENT_MAX_GAP_MS; + const peerBotTextFragmentGapMsForTest = + opts.testTimings?.peerBotTextFragmentGapMs ?? opts.testTimings?.textFragmentGapMs; + const TELEGRAM_PEER_BOT_TEXT_FRAGMENT_MAX_GAP_MS = + typeof peerBotTextFragmentGapMsForTest === "number" && + Number.isFinite(peerBotTextFragmentGapMsForTest) + ? Math.max(10, Math.floor(peerBotTextFragmentGapMsForTest)) + : DEFAULT_PEER_BOT_TEXT_FRAGMENT_MAX_GAP_MS; const TELEGRAM_TEXT_FRAGMENT_MAX_ID_GAP = 1; const TELEGRAM_TEXT_FRAGMENT_MAX_PARTS = 12; const TELEGRAM_TEXT_FRAGMENT_MAX_TOTAL_CHARS = 50_000; + const TELEGRAM_PEER_BOT_MAX_ACTIVE_TEXT_BATCHES = 4; const mediaGroupTimeoutMs = typeof opts.testTimings?.mediaGroupFlushMs === "number" && Number.isFinite(opts.testTimings.mediaGroupFlushMs) @@ -205,6 +235,10 @@ export const registerTelegramHandlers = ({ : MEDIA_GROUP_TIMEOUT_MS; type BufferedMediaGroupEntry = MediaGroupEntry & { + afterAdmissionCallbacks: Array<{ + messageId: number; + callback: TelegramDeferredAdmissionCallback; + }>; storeAllowFrom: string[]; isGroup: boolean; isForum: boolean; @@ -217,6 +251,9 @@ export const registerTelegramHandlers = ({ topicConfig?: TelegramTopicConfig; dispatchDedupeKeys: string[]; spooledReplayParticipants: TelegramSpooledReplayDeferredParticipant[]; + cancelPeerBotAdmission?: () => void; + peerBotAdmissionCanceled?: boolean; + peerBotCancellationCleanupComplete?: boolean; }; const mediaGroupBuffer = new Map(); @@ -234,13 +271,38 @@ export const registerTelegramHandlers = ({ key: string; threadId?: number; messages: Array<{ msg: Message; ctx: TelegramContext; receivedAtMs: number }>; + afterAdmissionCallbacks: Array<{ + messageId: number; + callback: TelegramDeferredAdmissionCallback; + }>; promptContextMinTimestampMs?: number; dispatchDedupeKeys: string[]; spooledReplayParticipants: TelegramSpooledReplayDeferredParticipant[]; + frameBatchId?: string; + maxGapMs: number; timer: ReturnType; + cancelPeerBotAdmission?: () => void; + peerBotAdmissionCanceled?: boolean; + peerBotCancellationCleanupComplete?: boolean; }; const textFragmentBuffer = new Map(); const textFragmentProcessingByKey = new Map>(); + const buildTextFragmentBufferKey = (params: { + chatId: number; + threadId?: number; + senderId: string; + frameBatchId?: string; + }) => { + const baseKey = `text:${params.chatId}:${params.threadId ?? "main"}:${params.senderId}`; + return params.frameBatchId ? `${baseKey}:frame:${params.frameBatchId}` : baseKey; + }; + const listFramedTextFragmentEntries = (baseKey: string) => + [...textFragmentBuffer.values()] + .filter((entry) => entry.key.startsWith(`${baseKey}:frame:`)) + .toSorted( + (left, right) => + (left.messages[0]?.receivedAtMs ?? 0) - (right.messages[0]?.receivedAtMs ?? 0), + ); const queueBufferedProcessing = async ( processingByKey: Map>, @@ -260,6 +322,7 @@ export const registerTelegramHandlers = ({ const FORWARD_BURST_DEBOUNCE_MS = 80; type TelegramDebounceLane = "default" | "forward"; type TelegramDebounceEntry = { + afterAdmissionShouldDrop?: TelegramDeferredAdmissionCallback; ctx: TelegramContext; msg: Message; allMedia: TelegramMediaRef[]; @@ -347,6 +410,17 @@ export const registerTelegramHandlers = ({ participant.settle(result); } }; + const settleDeferredAdmissions = async (params: { + callbacks: TelegramDeferredAdmissionCallback[]; + admitted: boolean; + cacheMessage: boolean; + context: string; + }) => { + const errors = await settleTelegramDeferredAdmissionCallbacks(params); + for (const error of errors) { + runtime.error?.(danger(`${params.context} admission cleanup failed: ${String(error)}`)); + } + }; const createSpooledReplayParticipantForBufferedWork = ( key: string, ): TelegramSpooledReplayDeferredParticipant | undefined => @@ -557,8 +631,10 @@ export const registerTelegramHandlers = ({ allMedia: last.allMedia, storeAllowFrom: last.storeAllowFrom, options: { + afterAdmissionShouldDrop: last.afterAdmissionShouldDrop, receivedAtMs: last.receivedAtMs, ingressBuffer: "inbound-debounce", + ...(last.threadId !== undefined ? { promptContextThreadId: last.threadId } : {}), ...promptContextBoundaryOptions(last.promptContextMinTimestampMs), ...spooledReplayOptions(spooledReplayParticipants), }, @@ -573,6 +649,11 @@ export const registerTelegramHandlers = ({ .join("\n"); const combinedMedia = entries.flatMap((entry) => entry.allMedia); if (!combinedText.trim() && combinedMedia.length === 0) { + await Promise.all( + entries.flatMap((entry) => + entry.afterAdmissionShouldDrop ? [entry.afterAdmissionShouldDrop(false)] : [], + ), + ); settleSpooledReplayParticipants(spooledReplayParticipants, { kind: "skipped" }); return; } @@ -588,15 +669,29 @@ export const registerTelegramHandlers = ({ }); const messageIdOverride = last.msg.message_id ? String(last.msg.message_id) : undefined; const syntheticCtx = buildSyntheticContext(baseCtx, syntheticMessage); + const deferredAdmissions = entries + .map((entry) => entry.afterAdmissionShouldDrop) + .filter( + (callback): callback is TelegramDeferredAdmissionCallback => callback !== undefined, + ); + const hasAlreadyAdmittedEntry = entries.some( + (entry) => entry.afterAdmissionShouldDrop === undefined, + ); const result = await processMessageWithReplyChain({ ctx: syntheticCtx, msg: syntheticMessage, allMedia: combinedMedia, storeAllowFrom: first.storeAllowFrom, options: { + afterAdmissionShouldDrop: combineTelegramDeferredAdmissionCallbacks( + deferredAdmissions, + deferredAdmissions[0], + !hasAlreadyAdmittedEntry, + ), ...(messageIdOverride ? { messageIdOverride } : {}), receivedAtMs: first.receivedAtMs, ingressBuffer: "inbound-debounce", + ...(first.threadId !== undefined ? { promptContextThreadId: first.threadId } : {}), ...promptContextBoundaryOptions(promptContextMinTimestampMs), ...spooledReplayOptions(spooledReplayParticipants), }, @@ -622,7 +717,21 @@ export const registerTelegramHandlers = ({ ); settleSpooledReplayParticipants(spooledReplayParticipants, buildFailedProcessingResult(err)); runtime.error?.(danger(`telegram debounce flush failed: ${String(err)}`)); - if (spooledReplayParticipants.length > 0) { + const deferredAdmissions = items + .map((item) => item.afterAdmissionShouldDrop) + .filter( + (callback): callback is (admitted: boolean) => Promise => callback !== undefined, + ); + if (deferredAdmissions.length > 0) { + void Promise.all(deferredAdmissions.map((callback) => callback(false))).catch( + (admissionErr: unknown) => { + runtime.error?.( + danger(`telegram deferred admission cleanup failed: ${String(admissionErr)}`), + ); + }, + ); + } + if (spooledReplayParticipants.length > 0 || deferredAdmissions.length > 0) { return; } const chatId = items[0]?.msg.chat.id; @@ -652,6 +761,18 @@ export const registerTelegramHandlers = ({ releaseDispatchDedupeKeys( mergeDispatchDedupeKeys(...items.map((item) => item.dispatchDedupeKeys)), ); + const deferredAdmissions = items + .map((item) => item.afterAdmissionShouldDrop) + .filter( + (callback): callback is TelegramDeferredAdmissionCallback => callback !== undefined, + ); + void Promise.all(deferredAdmissions.map((callback) => callback(false))).catch( + (admissionErr: unknown) => { + runtime.error?.( + danger(`telegram deferred admission cancellation failed: ${String(admissionErr)}`), + ); + }, + ); }, }); @@ -666,6 +787,9 @@ export const registerTelegramHandlers = ({ runtimeCfg?: OpenClawConfig; }): { agentId: string; + bindingMode: ReturnType["bindingMode"]; + route: ReturnType["route"]; + runtimeCfg: OpenClawConfig; sessionEntry: ReturnType["existing"]; sessionKey: string; model?: string; @@ -680,7 +804,7 @@ export const registerTelegramHandlers = ({ const dmThreadId = !params.isGroup ? params.messageThreadId : undefined; const topicThreadId = resolvedThreadId ?? dmThreadId; const { topicConfig } = resolveTelegramGroupConfig(params.chatId, topicThreadId); - const { route } = resolveTelegramConversationRoute({ + const { route, bindingMode } = resolveTelegramConversationRoute({ cfg: runtimeCfg, accountId, chatId: params.chatId, @@ -710,6 +834,7 @@ export const registerTelegramHandlers = ({ }); const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath); const entry = resolveSessionStoreEntry({ store, sessionKey }).existing; + const routeState = { bindingMode, route, runtimeCfg }; const storedOverride = resolveStoredModelOverride({ sessionEntry: entry, sessionStore: store, @@ -721,6 +846,7 @@ export const registerTelegramHandlers = ({ }); if (storedOverride) { return { + ...routeState, agentId: route.agentId, sessionEntry: entry, sessionKey, @@ -733,6 +859,7 @@ export const registerTelegramHandlers = ({ const model = entry?.model?.trim(); if (provider && model) { return { + ...routeState, agentId: route.agentId, sessionEntry: entry, sessionKey, @@ -741,6 +868,7 @@ export const registerTelegramHandlers = ({ } const modelCfg = runtimeCfg.agents?.defaults?.model; return { + ...routeState, agentId: route.agentId, sessionEntry: entry, sessionKey, @@ -757,7 +885,8 @@ export const registerTelegramHandlers = ({ return Boolean(msg.audio ?? msg.voice ?? documentMime?.startsWith("audio/")); }; - const shouldSkipMediaDownloadForUnaddressedMentionGroup = async (params: { + type TelegramMentionAdmission = "accepted" | "deferred" | "skipped"; + const resolveMediaDownloadMentionAdmission = async (params: { ctx: TelegramContext; msg: Message; chatId: number; @@ -770,7 +899,7 @@ export const registerTelegramHandlers = ({ effectiveDmAllow: NormalizedAllowFrom; groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig; - }): Promise => { + }): Promise => { const { ctx, msg, @@ -785,10 +914,9 @@ export const registerTelegramHandlers = ({ groupConfig, topicConfig, } = params; - if (!isGroup || mediaMayNeedDownloadForMentionDetection(msg)) { - return false; + if (!isGroup) { + return "accepted"; } - const runtimeCfg = telegramDeps.getRuntimeConfig(); const sessionState = resolveTelegramSessionState({ chatId, @@ -812,7 +940,7 @@ export const registerTelegramHandlers = ({ resolveGroupRequireMention(chatId), ); if (!requireMention) { - return false; + return "accepted"; } const botUsername = ctx.me?.username?.trim().toLowerCase(); @@ -874,15 +1002,41 @@ export const registerTelegramHandlers = ({ commandAuthorized: commandGate.authorized, }, }); - if (mentionDecision.shouldSkip) { - logger.info({ chatId, reason: "no-mention" }, "skipping group media before download"); - return true; + if (!mentionDecision.shouldSkip) { + return "accepted"; } - return false; + if (mediaMayNeedDownloadForMentionDetection(msg)) { + return "deferred"; + } + logger.info({ chatId, reason: "no-mention" }, "skipping group media before download"); + return "skipped"; + }; + + const releasePeerBotAdmissionCancellation = (entry: { cancelPeerBotAdmission?: () => void }) => { + entry.cancelPeerBotAdmission?.(); + entry.cancelPeerBotAdmission = undefined; + }; + + const completeCanceledBufferedEntry = ( + entry: Pick< + BufferedMediaGroupEntry | TextFragmentEntry, + "dispatchDedupeKeys" | "spooledReplayParticipants" | "peerBotCancellationCleanupComplete" + >, + ) => { + if (entry.peerBotCancellationCleanupComplete) { + return; + } + entry.peerBotCancellationCleanupComplete = true; + releaseDispatchDedupeKeys(entry.dispatchDedupeKeys); + settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" }); }; const processMediaGroup = async (entry: BufferedMediaGroupEntry) => { try { + if (entry.peerBotAdmissionCanceled) { + completeCanceledBufferedEntry(entry); + return; + } entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id); const captionMsg = entry.messages.find((m) => m.msg.caption || m.msg.text); @@ -893,26 +1047,63 @@ export const registerTelegramHandlers = ({ return; } + const mentionAdmission = await resolveMediaDownloadMentionAdmission({ + ctx: primaryEntry.ctx, + msg: primaryEntry.msg, + chatId: primaryEntry.msg.chat.id, + isGroup: entry.isGroup, + isForum: entry.isForum, + resolvedThreadId: entry.resolvedThreadId, + dmThreadId: entry.dmThreadId, + senderId: entry.senderId, + effectiveGroupAllow: entry.effectiveGroupAllow, + effectiveDmAllow: entry.effectiveDmAllow, + groupConfig: entry.groupConfig, + topicConfig: entry.topicConfig, + }); + if (mentionAdmission === "skipped") { + await Promise.all(entry.afterAdmissionCallbacks.map(({ callback }) => callback(false))); + releaseDispatchDedupeKeys(entry.dispatchDedupeKeys); + settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" }); + return; + } + + const admissionOwnerCallback = entry.afterAdmissionCallbacks[0]?.callback; + const deferredMessageIds = new Set( + entry.afterAdmissionCallbacks.map(({ messageId }) => messageId), + ); + const hasAlreadyAdmittedEntry = entry.messages.some( + ({ msg }) => !deferredMessageIds.has(msg.message_id), + ); + const combinedAdmission = combineTelegramDeferredAdmissionCallbacks( + entry.afterAdmissionCallbacks.map(({ callback }) => callback), + admissionOwnerCallback, + !hasAlreadyAdmittedEntry, + ); + let afterAdmissionShouldDrop: TelegramDeferredAdmissionCallback | undefined = + combinedAdmission + ? async (admitted, cacheMessage) => { + if (entry.peerBotAdmissionCanceled) { + await combinedAdmission(false, false); + return true; + } + return await combinedAdmission(admitted, cacheMessage); + } + : undefined; if ( - await shouldSkipMediaDownloadForUnaddressedMentionGroup({ - ctx: primaryEntry.ctx, - msg: primaryEntry.msg, - chatId: primaryEntry.msg.chat.id, - isGroup: entry.isGroup, - isForum: entry.isForum, - resolvedThreadId: entry.resolvedThreadId, - dmThreadId: entry.dmThreadId, - senderId: entry.senderId, - effectiveGroupAllow: entry.effectiveGroupAllow, - effectiveDmAllow: entry.effectiveDmAllow, - groupConfig: entry.groupConfig, - topicConfig: entry.topicConfig, - }) + mentionAdmission === "accepted" && + afterAdmissionShouldDrop && + (await afterAdmissionShouldDrop(true)) ) { releaseDispatchDedupeKeys(entry.dispatchDedupeKeys); settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" }); return; } + if (mentionAdmission === "accepted") { + // Accepted peer albums spend loop budget before any download; deferred + // mention detection retains the callback until media is available. + afterAdmissionShouldDrop = undefined; + } const allMedia: TelegramMediaRef[] = []; let skippedCount = 0; @@ -945,7 +1136,12 @@ export const registerTelegramHandlers = ({ } } - if (skippedCount > 0) { + if (entry.peerBotAdmissionCanceled) { + completeCanceledBufferedEntry(entry); + return; + } + + if (skippedCount > 0 && entry.afterAdmissionCallbacks.length === 0) { const total = entry.messages.length; const wasOrWere = skippedCount === 1 ? "was" : "were"; await withTelegramApiErrorLogging({ @@ -971,6 +1167,10 @@ export const registerTelegramHandlers = ({ allMedia, storeAllowFrom: entry.storeAllowFrom, options: { + afterAdmissionShouldDrop, + ...(entry.resolvedThreadId !== undefined || entry.dmThreadId !== undefined + ? { promptContextThreadId: entry.resolvedThreadId ?? entry.dmThreadId } + : {}), ...promptContextBoundaryOptions(entry.promptContextMinTimestampMs), ...spooledReplayOptions(entry.spooledReplayParticipants), }, @@ -978,53 +1178,124 @@ export const registerTelegramHandlers = ({ }); settleSpooledReplayParticipants(entry.spooledReplayParticipants, result); } catch (err) { + await settleDeferredAdmissions({ + callbacks: entry.afterAdmissionCallbacks.map(({ callback }) => callback), + admitted: false, + cacheMessage: false, + context: "media group", + }); releaseDispatchDedupeKeys(entry.dispatchDedupeKeys, err); settleSpooledReplayParticipants( entry.spooledReplayParticipants, buildFailedProcessingResult(err), ); runtime.error?.(danger(`media group handler failed: ${String(err)}`)); + } finally { + releasePeerBotAdmissionCancellation(entry); } }; + const detachMediaGroupEntry = (key: string, entry: BufferedMediaGroupEntry) => { + clearTimeout(entry.timer); + if (mediaGroupBuffer.get(key) === entry) { + mediaGroupBuffer.delete(key); + } + }; + + const dropMediaGroupEntry = async (key: string, entry: BufferedMediaGroupEntry) => { + detachMediaGroupEntry(key, entry); + await settleDeferredAdmissions({ + callbacks: entry.afterAdmissionCallbacks.map(({ callback }) => callback), + admitted: false, + cacheMessage: false, + context: "media group cancellation", + }); + completeCanceledBufferedEntry(entry); + releasePeerBotAdmissionCancellation(entry); + }; + const flushTextFragments = async (entry: TextFragmentEntry) => { try { + if (entry.peerBotAdmissionCanceled) { + completeCanceledBufferedEntry(entry); + return; + } entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id); const first = entry.messages[0]; const last = entry.messages.at(-1); if (!first || !last) { + await Promise.all(entry.afterAdmissionCallbacks.map(({ callback }) => callback(false))); releaseDispatchDedupeKeys(entry.dispatchDedupeKeys); settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" }); return; } - const combinedText = entry.messages.map((m) => m.msg.text ?? "").join(""); + const combinedText = entry.messages + .map((m) => { + const text = m.msg.text ?? ""; + return stripTelegramStandardFragmentMarker(text); + }) + .join(""); if (!combinedText.trim()) { + await Promise.all(entry.afterAdmissionCallbacks.map(({ callback }) => callback(false))); releaseDispatchDedupeKeys(entry.dispatchDedupeKeys); settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" }); return; } const syntheticMessage = buildSyntheticTextMessage({ - base: first.msg, + base: { ...first.msg, message_id: last.msg.message_id }, text: combinedText, date: last.msg.date ?? first.msg.date, }); const storeAllowFrom = await loadStoreAllowFrom(first.msg); + if (entry.peerBotAdmissionCanceled) { + completeCanceledBufferedEntry(entry); + return; + } const baseCtx = first.ctx; const syntheticCtx = buildSyntheticContext(baseCtx, syntheticMessage); + const deferredMessageIds = new Set( + entry.afterAdmissionCallbacks.map(({ messageId }) => messageId), + ); + const hasAlreadyAdmittedEntry = entry.messages.some( + ({ msg }) => !deferredMessageIds.has(msg.message_id), + ); + const combinedAdmission = combineTelegramDeferredAdmissionCallbacks( + entry.afterAdmissionCallbacks.map(({ callback }) => callback), + entry.afterAdmissionCallbacks[0]?.callback, + !hasAlreadyAdmittedEntry, + ); + let syntheticMessageRecorded = false; + const normalizedCombinedAdmission: TelegramDeferredAdmissionCallback | undefined = + combinedAdmission + ? async (admitted) => { + if (entry.peerBotAdmissionCanceled) { + await combinedAdmission(false, false); + return true; + } + const shouldDrop = await combinedAdmission(admitted, false); + if (!shouldDrop && !syntheticMessageRecorded) { + await recordMessageForReplyChain(syntheticMessage, entry.threadId); + syntheticMessageRecorded = true; + } + return shouldDrop; + } + : undefined; const result = await processMessageWithReplyChain({ ctx: syntheticCtx, msg: syntheticMessage, allMedia: [], storeAllowFrom, options: { + afterAdmissionShouldDrop: normalizedCombinedAdmission, messageIdOverride: String(last.msg.message_id), receivedAtMs: first.receivedAtMs, ingressBuffer: "text-fragment", + ...(entry.threadId !== undefined ? { promptContextThreadId: entry.threadId } : {}), ...promptContextBoundaryOptions(entry.promptContextMinTimestampMs), ...spooledReplayOptions(entry.spooledReplayParticipants), }, @@ -1032,12 +1303,20 @@ export const registerTelegramHandlers = ({ }); settleSpooledReplayParticipants(entry.spooledReplayParticipants, result); } catch (err) { + await settleDeferredAdmissions({ + callbacks: entry.afterAdmissionCallbacks.map(({ callback }) => callback), + admitted: false, + cacheMessage: false, + context: "text fragment", + }); releaseDispatchDedupeKeys(entry.dispatchDedupeKeys, err); settleSpooledReplayParticipants( entry.spooledReplayParticipants, buildFailedProcessingResult(err), ); runtime.error?.(danger(`text fragment handler failed: ${String(err)}`)); + } finally { + releasePeerBotAdmissionCancellation(entry); } }; @@ -1047,7 +1326,24 @@ export const registerTelegramHandlers = ({ }); }; + const dropTextFragmentEntry = async (entry: TextFragmentEntry) => { + clearTimeout(entry.timer); + textFragmentBuffer.delete(entry.key); + await settleDeferredAdmissions({ + callbacks: entry.afterAdmissionCallbacks.map(({ callback }) => callback), + admitted: false, + cacheMessage: false, + context: "text fragment cancellation", + }); + completeCanceledBufferedEntry(entry); + releasePeerBotAdmissionCancellation(entry); + }; + const runTextFragmentFlush = async (entry: TextFragmentEntry) => { + if (entry.frameBatchId !== undefined) { + await dropTextFragmentEntry(entry); + return; + } textFragmentBuffer.delete(entry.key); await queueTextFragmentFlush(entry); }; @@ -1056,7 +1352,7 @@ export const registerTelegramHandlers = ({ clearTimeout(entry.timer); entry.timer = setTimeout(() => { void runTextFragmentFlush(entry); - }, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS); + }, entry.maxGapMs); }; const loadStoreAllowFrom = async (msg: Message) => { @@ -1139,7 +1435,9 @@ export const registerTelegramHandlers = ({ chatId: msg.chat.id, messageId, }); - const threadId = currentNode?.threadId ? Number(currentNode.threadId) : undefined; + const threadId = + options?.promptContextThreadId ?? + (currentNode?.threadId ? Number(currentNode.threadId) : undefined); const conversationContext = await buildTelegramConversationContext({ cache: messageCache, messageId, @@ -1750,8 +2048,13 @@ export const registerTelegramHandlers = ({ topicConfig?: TelegramTopicConfig; sendOversizeWarning: boolean; oversizeLogMessage: string; + afterAdmissionShouldDrop?: TelegramDeferredAdmissionCallback; + onAdmissionCallbackChanged?: (callback: TelegramDeferredAdmissionCallback) => void; + peerBotAdmissionKey?: string; + mentionAdmissionAccepted?: boolean; promptContextMinTimestampMs?: number; dispatchDedupeKeys: string[]; + spooledReplayParticipant?: TelegramSpooledReplayDeferredParticipant; }) => { const { ctx, @@ -1770,9 +2073,15 @@ export const registerTelegramHandlers = ({ topicConfig, sendOversizeWarning, oversizeLogMessage, + afterAdmissionShouldDrop, + onAdmissionCallbackChanged, + peerBotAdmissionKey, + mentionAdmissionAccepted, promptContextMinTimestampMs, dispatchDedupeKeys, + spooledReplayParticipant, } = params; + let effectiveAfterAdmissionShouldDrop = afterAdmissionShouldDrop; const messageText = getTelegramTextParts(msg).text; const botUsername = ctx.me?.username; @@ -1805,26 +2114,47 @@ export const registerTelegramHandlers = ({ // Text fragment handling - Telegram splits long pastes into multiple inbound messages (~4096 chars). // We buffer “near-limit” messages and append immediately-following parts. const text = typeof msg.text === "string" ? msg.text : undefined; + const isPeerBotMessage = msg.from?.is_bot === true && msg.sender_chat == null; const isCommandLike = (text ?? "").trim().startsWith("/"); + const nowMs = Date.now(); + const senderIdValue = msg.from?.id != null ? String(msg.from.id) : "unknown"; + // Use resolvedThreadId for forum groups, dmThreadId for DM topics. + const threadId = resolvedThreadId ?? dmThreadId; + const standardFragment = + text && isPeerBotMessage ? resolveTelegramStandardFragmentFrame(text) : undefined; + const key = buildTextFragmentBufferKey({ + chatId, + threadId, + senderId: senderIdValue, + frameBatchId: standardFragment?.batchId, + }); + const existing = text ? textFragmentBuffer.get(key) : undefined; if (text && !isCommandLike && !isAbortControlMessage) { - const nowMs = Date.now(); - const senderIdValue = msg.from?.id != null ? String(msg.from.id) : "unknown"; - // Use resolvedThreadId for forum groups, dmThreadId for DM topics - const threadId = resolvedThreadId ?? dmThreadId; - const key = `text:${chatId}:${threadId ?? "main"}:${senderIdValue}`; - const existing = textFragmentBuffer.get(key); - + const standardFragmentKind = standardFragment?.kind; if (existing) { const last = existing.messages.at(-1); const lastMsgId = last?.msg.message_id; const lastReceivedAtMs = last?.receivedAtMs ?? nowMs; const idGap = typeof lastMsgId === "number" ? msg.message_id - lastMsgId : Infinity; const timeGapMs = nowMs - lastReceivedAtMs; - const canAppend = + const isMarkedPeerBotContinuation = + standardFragmentKind === "continuation" || standardFragmentKind === "end"; + const matchesFramedBatch = + existing.frameBatchId !== undefined && + isMarkedPeerBotContinuation && + standardFragment?.batchId === existing.frameBatchId; + // Framed peer batches accept only continuation/end frames across group IDs; + // unframed pastes retain adjacency so separate turns cannot collapse. + const preservesFragmentOrder = + standardFragmentKind !== "start" && idGap > 0 && - idGap <= TELEGRAM_TEXT_FRAGMENT_MAX_ID_GAP && - timeGapMs >= 0 && - timeGapMs <= TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS; + (matchesFramedBatch || + (!isPeerBotMessage && + standardFragment === undefined && + existing.frameBatchId === undefined && + idGap <= TELEGRAM_TEXT_FRAGMENT_MAX_ID_GAP)); + const canAppend = + preservesFragmentOrder && timeGapMs >= 0 && timeGapMs <= existing.maxGapMs; if (canAppend) { const currentTotalChars = existing.messages.reduce( @@ -1832,17 +2162,30 @@ export const registerTelegramHandlers = ({ 0, ); const nextTotalChars = currentTotalChars + text.length; - if ( - existing.messages.length + 1 <= TELEGRAM_TEXT_FRAGMENT_MAX_PARTS && - nextTotalChars <= TELEGRAM_TEXT_FRAGMENT_MAX_TOTAL_CHARS - ) { - const spooledReplayParticipant = createSpooledReplayParticipantForBufferedWork( + const maxParts = + existing.frameBatchId !== undefined + ? TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS + : TELEGRAM_TEXT_FRAGMENT_MAX_PARTS; + const maxTotalChars = + existing.frameBatchId !== undefined + ? TELEGRAM_STANDARD_FRAGMENT_MAX_WIRE_CHARS + : TELEGRAM_TEXT_FRAGMENT_MAX_TOTAL_CHARS; + const withinBatchLimits = + existing.messages.length + 1 <= maxParts && nextTotalChars <= maxTotalChars; + if (withinBatchLimits) { + const continuationReplayParticipant = createSpooledReplayParticipantForBufferedWork( `text-fragment:${key}:${msg.message_id}`, ); - if (spooledReplayParticipant) { - existing.spooledReplayParticipants.push(spooledReplayParticipant); + if (continuationReplayParticipant) { + existing.spooledReplayParticipants.push(continuationReplayParticipant); } existing.messages.push({ msg, ctx, receivedAtMs: nowMs }); + if (effectiveAfterAdmissionShouldDrop) { + existing.afterAdmissionCallbacks.push({ + messageId: msg.message_id, + callback: effectiveAfterAdmissionShouldDrop, + }); + } existing.promptContextMinTimestampMs = latestPromptContextMinTimestampMs( existing.promptContextMinTimestampMs, promptContextMinTimestampMs, @@ -1851,82 +2194,192 @@ export const registerTelegramHandlers = ({ existing.dispatchDedupeKeys, dispatchDedupeKeys, ); - scheduleTextFragmentFlush(existing); + if (standardFragmentKind === "end") { + clearTimeout(existing.timer); + textFragmentBuffer.delete(key); + // A later batch can complete before an earlier admission owner. + // Flush off-lane so Telegram's sequentializer can receive that owner. + void queueTextFragmentFlush(existing); + } else { + scheduleTextFragmentFlush(existing); + } + return; + } + if (existing.frameBatchId !== undefined && matchesFramedBatch) { + // A framed overflow is one invalid transport batch. Drop it whole so + // no prefix or tail can become an independent agent turn. + await dropTextFragmentEntry(existing); + await effectiveAfterAdmissionShouldDrop?.(false, false); + releaseDispatchDedupeKeys(dispatchDedupeKeys); + runtime.error?.( + danger( + `telegram framed text batch exceeded ${maxParts} parts or ${maxTotalChars} characters`, + ), + ); return; } } - // Not appendable (or limits exceeded): flush buffered entry first, then continue normally. - clearTimeout(existing.timer); - textFragmentBuffer.delete(key); - await queueTextFragmentFlush(existing); + // An incomplete framed transport batch is not an agent turn. A new + // start or ordinary message abandons it rather than exposing a prefix. + if (existing.frameBatchId !== undefined) { + await dropTextFragmentEntry(existing); + } else { + clearTimeout(existing.timer); + textFragmentBuffer.delete(key); + await queueTextFragmentFlush(existing); + } + if (isPeerBotMessage) { + const priorAdmission = effectiveAfterAdmissionShouldDrop; + const replacementAdmission = peerBotAdmission.reserve( + buildTelegramPeerBotAdmissionKey({ + accountId, + chatId, + threadId, + senderId, + receiverId: ctx.me?.id, + }), + async (admitted, cacheMessage = true) => { + if (admitted && shouldSuppressTelegramPeerBotTurn({ ctx, cfg, accountId })) { + await priorAdmission?.(false, false); + return true; + } + return (await priorAdmission?.(admitted, cacheMessage)) ?? false; + }, + ); + effectiveAfterAdmissionShouldDrop = replacementAdmission; + onAdmissionCallbackChanged?.(replacementAdmission); + } } - const shouldStart = text.length >= TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS; + if (standardFragment && standardFragment.kind !== "start") { + // Continuations are valid only while their exact batch owns this key. + // Orphans must never surface hidden transport framing to the agent. + await effectiveAfterAdmissionShouldDrop?.(false, false); + releaseDispatchDedupeKeys(dispatchDedupeKeys); + return; + } + + const shouldStart = + !isCommandLike && + !isAbortControlMessage && + text.length >= TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS; if (shouldStart) { - const spooledReplayParticipant = createSpooledReplayParticipantForBufferedWork( + const initialReplayParticipant = createSpooledReplayParticipantForBufferedWork( `text-fragment:${key}:${msg.message_id}`, ); const entry: TextFragmentEntry = { key, + ...(threadId !== undefined ? { threadId } : {}), messages: [{ msg, ctx, receivedAtMs: nowMs }], + afterAdmissionCallbacks: effectiveAfterAdmissionShouldDrop + ? [{ messageId: msg.message_id, callback: effectiveAfterAdmissionShouldDrop }] + : [], dispatchDedupeKeys, - spooledReplayParticipants: spooledReplayParticipant ? [spooledReplayParticipant] : [], + spooledReplayParticipants: initialReplayParticipant ? [initialReplayParticipant] : [], ...promptContextBoundaryOptions(promptContextMinTimestampMs), + ...(standardFragment?.kind === "start" ? { frameBatchId: standardFragment.batchId } : {}), + maxGapMs: + standardFragmentKind === "start" + ? TELEGRAM_PEER_BOT_TEXT_FRAGMENT_MAX_GAP_MS + : TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS, timer: setTimeout(() => {}, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS), }; textFragmentBuffer.set(key, entry); + if (peerBotAdmissionKey) { + entry.cancelPeerBotAdmission = peerBotAdmission.registerCancellation( + peerBotAdmissionKey, + async () => { + entry.peerBotAdmissionCanceled = true; + if (textFragmentBuffer.get(entry.key) === entry) { + await dropTextFragmentEntry(entry); + return; + } + await Promise.all( + entry.afterAdmissionCallbacks.map(({ callback }) => callback(false, false)), + ); + }, + ); + } scheduleTextFragmentFlush(entry); return; } - } else if (text && isAbortControlMessage && (await isAuthorizedAbortControlMessage())) { - const senderIdLocal = msg.from?.id != null ? String(msg.from.id) : "unknown"; - const threadId = resolvedThreadId ?? dmThreadId; - const key = `text:${chatId}:${threadId ?? "main"}:${senderIdLocal}`; - const existing = textFragmentBuffer.get(key); - if (existing) { - clearTimeout(existing.timer); - textFragmentBuffer.delete(key); - releaseDispatchDedupeKeys(existing.dispatchDedupeKeys); - settleSpooledReplayParticipants(existing.spooledReplayParticipants, { kind: "skipped" }); + } + const authorizedAbortControl = + text && isAbortControlMessage ? await isAuthorizedAbortControlMessage() : false; + if (authorizedAbortControl && peerBotAdmissionKey) { + // Stop cancels both buffered and already-flushing peer work before loop + // protection can suppress the visible control turn. + await peerBotAdmission.cancel(peerBotAdmissionKey); + } + if (text && authorizedAbortControl) { + const baseKey = buildTextFragmentBufferKey({ + chatId, + threadId, + senderId: senderIdValue, + }); + const bufferedTextEntry = textFragmentBuffer.get(baseKey); + const bufferedEntries = [ + ...(bufferedTextEntry ? [bufferedTextEntry] : []), + ...listFramedTextFragmentEntries(baseKey), + ]; + for (const entry of bufferedEntries) { + await dropTextFragmentEntry(entry); } } + if ( + authorizedAbortControl && + peerBotAdmissionKey && + shouldSuppressTelegramPeerBotTurn({ ctx, cfg, accountId }) + ) { + releaseDispatchDedupeKeys(dispatchDedupeKeys); + return; + } // Media group handling - buffer multi-image messages const mediaGroupId = msg.media_group_id; if (mediaGroupId) { - const threadId = resolvedThreadId ?? dmThreadId; - const mediaGroupKey = `media:${chatId}:${threadId ?? "main"}:${mediaGroupId}`; - const existing = mediaGroupBuffer.get(mediaGroupKey); - if (existing) { - const spooledReplayParticipant = createSpooledReplayParticipantForBufferedWork( + const mediaThreadId = resolvedThreadId ?? dmThreadId; + const mediaGroupKey = `media:${chatId}:${mediaThreadId ?? "main"}:${mediaGroupId}`; + const mediaGroupEntry = mediaGroupBuffer.get(mediaGroupKey); + if (mediaGroupEntry) { + const continuationReplayParticipant = createSpooledReplayParticipantForBufferedWork( `media-group:${mediaGroupKey}:${msg.message_id}`, ); - if (spooledReplayParticipant) { - existing.spooledReplayParticipants.push(spooledReplayParticipant); + if (continuationReplayParticipant) { + mediaGroupEntry.spooledReplayParticipants.push(continuationReplayParticipant); } - clearTimeout(existing.timer); - existing.messages.push({ msg, ctx }); - existing.promptContextMinTimestampMs = latestPromptContextMinTimestampMs( - existing.promptContextMinTimestampMs, + clearTimeout(mediaGroupEntry.timer); + mediaGroupEntry.messages.push({ msg, ctx }); + if (effectiveAfterAdmissionShouldDrop) { + mediaGroupEntry.afterAdmissionCallbacks.push({ + messageId: msg.message_id, + callback: effectiveAfterAdmissionShouldDrop, + }); + } + mediaGroupEntry.promptContextMinTimestampMs = latestPromptContextMinTimestampMs( + mediaGroupEntry.promptContextMinTimestampMs, promptContextMinTimestampMs, ); - existing.dispatchDedupeKeys = mergeDispatchDedupeKeys( - existing.dispatchDedupeKeys, + mediaGroupEntry.dispatchDedupeKeys = mergeDispatchDedupeKeys( + mediaGroupEntry.dispatchDedupeKeys, dispatchDedupeKeys, ); - existing.timer = setTimeout(() => { - mediaGroupBuffer.delete(mediaGroupKey); + mediaGroupEntry.timer = setTimeout(() => { + detachMediaGroupEntry(mediaGroupKey, mediaGroupEntry); void queueBufferedProcessing(mediaGroupProcessingByKey, mediaGroupKey, async () => { - await processMediaGroup(existing); + await processMediaGroup(mediaGroupEntry); }); }, mediaGroupTimeoutMs); } else { - const spooledReplayParticipant = createSpooledReplayParticipantForBufferedWork( + const initialReplayParticipant = createSpooledReplayParticipantForBufferedWork( `media-group:${mediaGroupKey}:${msg.message_id}`, ); const entry: BufferedMediaGroupEntry = { messages: [{ msg, ctx }], + afterAdmissionCallbacks: effectiveAfterAdmissionShouldDrop + ? [{ messageId: msg.message_id, callback: effectiveAfterAdmissionShouldDrop }] + : [], storeAllowFrom, isGroup, isForum, @@ -1938,22 +2391,38 @@ export const registerTelegramHandlers = ({ groupConfig, topicConfig, dispatchDedupeKeys, - spooledReplayParticipants: spooledReplayParticipant ? [spooledReplayParticipant] : [], + spooledReplayParticipants: initialReplayParticipant ? [initialReplayParticipant] : [], ...promptContextBoundaryOptions(promptContextMinTimestampMs), timer: setTimeout(() => { - mediaGroupBuffer.delete(mediaGroupKey); + detachMediaGroupEntry(mediaGroupKey, entry); void queueBufferedProcessing(mediaGroupProcessingByKey, mediaGroupKey, async () => { await processMediaGroup(entry); }); }, mediaGroupTimeoutMs), }; mediaGroupBuffer.set(mediaGroupKey, entry); + if (peerBotAdmissionKey) { + entry.cancelPeerBotAdmission = peerBotAdmission.registerCancellation( + peerBotAdmissionKey, + async () => { + entry.peerBotAdmissionCanceled = true; + if (mediaGroupBuffer.get(mediaGroupKey) === entry) { + await dropMediaGroupEntry(mediaGroupKey, entry); + return; + } + await Promise.all( + entry.afterAdmissionCallbacks.map(({ callback }) => callback(false, false)), + ); + }, + ); + } } return; } if ( - await shouldSkipMediaDownloadForUnaddressedMentionGroup({ + mentionAdmissionAccepted !== true && + (await resolveMediaDownloadMentionAdmission({ ctx, msg, chatId, @@ -1966,8 +2435,9 @@ export const registerTelegramHandlers = ({ effectiveDmAllow, groupConfig, topicConfig, - }) + })) === "skipped" ) { + await effectiveAfterAdmissionShouldDrop?.(false); releaseDispatchDedupeKeys(dispatchDedupeKeys); return; } @@ -1981,7 +2451,9 @@ export const registerTelegramHandlers = ({ }); } catch (mediaErr) { if (isMediaSizeLimitError(mediaErr)) { - if (sendOversizeWarning) { + if (effectiveAfterAdmissionShouldDrop) { + await effectiveAfterAdmissionShouldDrop(false); + } else if (sendOversizeWarning) { const limitMb = Math.round(mediaMaxBytes / (1024 * 1024)); await withTelegramApiErrorLogging({ operation: "sendMessage", @@ -2000,17 +2472,21 @@ export const registerTelegramHandlers = ({ return; } logger.warn({ chatId, error: String(mediaErr) }, "media fetch failed"); - await withTelegramApiErrorLogging({ - operation: "sendMessage", - runtime, - fn: () => - bot.api.sendMessage(chatId, "⚠️ Failed to download media. Please try again.", { - reply_parameters: { - message_id: msg.message_id, - allow_sending_without_reply: true, - }, - }), - }).catch(() => {}); + if (effectiveAfterAdmissionShouldDrop) { + await effectiveAfterAdmissionShouldDrop(false); + } else { + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime, + fn: () => + bot.api.sendMessage(chatId, "⚠️ Failed to download media. Please try again.", { + reply_parameters: { + message_id: msg.message_id, + allow_sending_without_reply: true, + }, + }), + }).catch(() => {}); + } releaseDispatchDedupeKeys(dispatchDedupeKeys); return; } @@ -2020,6 +2496,7 @@ export const registerTelegramHandlers = ({ const hasText = Boolean(getTelegramTextParts(msg).text.trim()); if (msg.sticker && !media && !hasText) { logVerbose("telegram: skipping sticker-only message (unsupported sticker type)"); + await effectiveAfterAdmissionShouldDrop?.(false); releaseDispatchDedupeKeys(dispatchDedupeKeys); return; } @@ -2067,15 +2544,18 @@ export const registerTelegramHandlers = ({ debounceKey: isAbortControlMessage ? null : debounceKey, debounceLane, botUsername, + afterAdmissionShouldDrop: effectiveAfterAdmissionShouldDrop, + threadId: resolvedThreadId ?? dmThreadId, ...promptContextBoundaryOptions(promptContextMinTimestampMs), dispatchDedupeKeys, + ...(spooledReplayParticipant ? { spooledReplayParticipant } : {}), }; if ( debounceEntry.debounceKey && resolveTelegramDebounceEntryMs(debounceEntry) > 0 && shouldDebounceTelegramEntry(debounceEntry) ) { - debounceEntry.spooledReplayParticipant = createSpooledReplayParticipantForBufferedWork( + debounceEntry.spooledReplayParticipant ??= createSpooledReplayParticipantForBufferedWork( `inbound-debounce:${debounceEntry.debounceKey}`, ); } @@ -3039,6 +3519,7 @@ export const registerTelegramHandlers = ({ const handleInboundMessageLike = async (event: InboundTelegramEvent) => { let dispatchDedupeKeys: string[] = []; + let pendingPeerBotAdmission: TelegramDeferredAdmissionCallback | undefined; try { if (shouldSkipUpdate(event.ctxForDedupe)) { return; @@ -3068,17 +3549,19 @@ export const registerTelegramHandlers = ({ effectiveGroupAllow, } = gate.context; + const inboundRuntimeCfg = telegramDeps.getRuntimeConfig(); + const inboundSessionState = resolveTelegramSessionState({ + chatId: event.chatId, + isGroup: event.isGroup, + isForum: event.isForum, + messageThreadId: event.messageThreadId, + resolvedThreadId, + botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(event.ctx.me), + senderId: event.senderId, + runtimeCfg: inboundRuntimeCfg, + }); const promptContextMinTimestampMs = normalizePromptContextMinTimestampMs( - resolveTelegramSessionState({ - chatId: event.chatId, - isGroup: event.isGroup, - isForum: event.isForum, - messageThreadId: event.messageThreadId, - resolvedThreadId, - botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(event.ctx.me), - senderId: event.senderId, - runtimeCfg: cfg, - }).sessionEntry?.sessionStartedAt, + inboundSessionState.sessionEntry?.sessionStartedAt, ); const dispatchDedupe = await claimMessageDispatchDedupe(event.msg); @@ -3086,8 +3569,175 @@ export const registerTelegramHandlers = ({ return; } dispatchDedupeKeys = dispatchDedupe.keys; - await recordMessageForReplyChain(event.msg, resolvedThreadId ?? dmThreadId); - await processInboundMessage({ + const isPeerBotEvent = event.msg.from?.is_bot === true && event.msg.sender_chat == null; + const botMentionAdmission = isPeerBotEvent + ? await resolveMediaDownloadMentionAdmission({ + ctx: event.ctx, + msg: event.msg, + chatId: event.chatId, + isGroup: event.isGroup, + isForum: event.isForum, + resolvedThreadId, + dmThreadId, + senderId: event.senderId, + effectiveGroupAllow, + effectiveDmAllow, + groupConfig: event.isGroup + ? (groupConfig as TelegramGroupConfig | undefined) + : undefined, + topicConfig, + }) + : undefined; + const deferPeerBotMediaGroupAdmission = isPeerBotEvent && Boolean(event.msg.media_group_id); + const eventText = typeof event.msg.text === "string" ? event.msg.text : undefined; + const isPeerBotAbortControl = + isPeerBotEvent && + eventText !== undefined && + isAbortRequestText(eventText, { botUsername: event.ctx.me?.username }); + const eventStandardFragment = + isPeerBotEvent && eventText !== undefined + ? resolveTelegramStandardFragmentFrame(eventText) + : undefined; + const textFragmentKey = buildTextFragmentBufferKey({ + chatId: event.chatId, + threadId: resolvedThreadId ?? dmThreadId, + senderId: event.senderId || "unknown", + frameBatchId: eventStandardFragment?.batchId, + }); + const textFragmentBaseKey = buildTextFragmentBufferKey({ + chatId: event.chatId, + threadId: resolvedThreadId ?? dmThreadId, + senderId: event.senderId || "unknown", + }); + const activeFramedBatches = isPeerBotEvent + ? listFramedTextFragmentEntries(textFragmentBaseKey) + : []; + if (isPeerBotEvent) { + if (eventStandardFragment?.kind === "abort") { + const abortedBatch = textFragmentBuffer.get(textFragmentKey); + if (abortedBatch) { + await dropTextFragmentEntry(abortedBatch); + } + releaseDispatchDedupeKeys(dispatchDedupeKeys); + return; + } + if (eventStandardFragment?.kind === "start") { + const sameBatch = textFragmentBuffer.get(textFragmentKey); + if (sameBatch) { + await dropTextFragmentEntry(sameBatch); + } + const retireCount = Math.max( + 0, + activeFramedBatches.length - TELEGRAM_PEER_BOT_MAX_ACTIVE_TEXT_BATCHES + 1, + ); + for (const entry of activeFramedBatches.slice(0, retireCount)) { + await dropTextFragmentEntry(entry); + } + } else if ( + eventStandardFragment !== undefined && + !textFragmentBuffer.has(textFragmentKey) + ) { + // Lost or retired batches cannot own admission or message-cache state. + releaseDispatchDedupeKeys(dispatchDedupeKeys); + return; + } + } + const mediaGroupKey = event.msg.media_group_id + ? `media:${event.chatId}:${resolvedThreadId ?? dmThreadId ?? "main"}:${event.msg.media_group_id}` + : undefined; + const deferPeerBotTextFragmentAdmission = + isPeerBotEvent && + eventText !== undefined && + !eventText.trim().startsWith("/") && + !isPeerBotAbortControl && + (eventText.length >= TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS || + textFragmentBuffer.has(textFragmentKey)); + const deferStandalonePeerBehindFrames = + isPeerBotEvent && + eventStandardFragment === undefined && + !isPeerBotAbortControl && + !deferPeerBotMediaGroupAdmission && + activeFramedBatches.length > 0; + const deferPeerBotBufferedAdmission = + deferPeerBotMediaGroupAdmission || + deferPeerBotTextFragmentAdmission || + deferStandalonePeerBehindFrames; + const recordCurrentMessage = async () => + await recordMessageForReplyChain(event.msg, resolvedThreadId ?? dmThreadId); + let currentMessageRecorded = false; + const isFirstBufferedPeerBotEvent = + (deferPeerBotMediaGroupAdmission && + mediaGroupKey !== undefined && + !mediaGroupBuffer.has(mediaGroupKey)) || + (deferPeerBotTextFragmentAdmission && !textFragmentBuffer.has(textFragmentKey)) || + deferStandalonePeerBehindFrames; + const shouldReservePeerBotAdmission = + isPeerBotEvent && + // Abort controls must bypass ordering so they can cancel the reservation + // they would otherwise wait behind. + !isPeerBotAbortControl && + (botMentionAdmission === "accepted" || + botMentionAdmission === "deferred" || + deferPeerBotBufferedAdmission) && + (!deferPeerBotBufferedAdmission || isFirstBufferedPeerBotEvent); + const peerBotAdmissionKey = isPeerBotEvent + ? buildTelegramPeerBotAdmissionKey({ + accountId, + chatId: event.chatId, + threadId: resolvedThreadId ?? dmThreadId, + senderId: event.senderId, + receiverId: event.ctx.me?.id, + }) + : undefined; + const orderedPeerBotAdmission = + shouldReservePeerBotAdmission && peerBotAdmissionKey + ? peerBotAdmission.reserve( + peerBotAdmissionKey, + async (admitted) => + admitted && shouldSuppressTelegramPeerBotTurn({ ctx: event.ctx, cfg, accountId }), + ) + : undefined; + const shouldFinalizePeerBotAdmission = + orderedPeerBotAdmission !== undefined || + botMentionAdmission === "deferred" || + deferPeerBotBufferedAdmission; + let afterAdmissionShouldDrop: TelegramDeferredAdmissionCallback | undefined = + shouldFinalizePeerBotAdmission + ? async (admitted: boolean, cacheMessage = true) => { + if (orderedPeerBotAdmission && (await orderedPeerBotAdmission(admitted))) { + return true; + } + if (cacheMessage && !currentMessageRecorded) { + await recordCurrentMessage(); + currentMessageRecorded = true; + } + return false; + } + : undefined; + pendingPeerBotAdmission = afterAdmissionShouldDrop ?? orderedPeerBotAdmission; + const routeNeedsLateAdmission = + inboundSessionState.bindingMode.kind === "configured" || + (event.isGroup && + inboundSessionState.route.matchedBy === "default" && + normalizeAccountId(inboundSessionState.route.accountId) !== + normalizeAccountId(resolveDefaultTelegramAccountId(inboundSessionState.runtimeCfg))); + const canFinalizePeerBotBeforeMedia = + botMentionAdmission === "accepted" && + !deferPeerBotBufferedAdmission && + !routeNeedsLateAdmission; + if (afterAdmissionShouldDrop && canFinalizePeerBotBeforeMedia) { + if (await afterAdmissionShouldDrop(true)) { + releaseDispatchDedupeKeys(dispatchDedupeKeys); + return; + } + pendingPeerBotAdmission = undefined; + afterAdmissionShouldDrop = undefined; + } + if (!afterAdmissionShouldDrop && !currentMessageRecorded) { + await recordCurrentMessage(); + currentMessageRecorded = true; + } + const processInboundParams: Parameters[0] = { ctx: event.ctx, msg: event.msg, chatId: event.chatId, @@ -3104,11 +3754,54 @@ export const registerTelegramHandlers = ({ topicConfig, sendOversizeWarning: event.sendOversizeWarning, oversizeLogMessage: event.oversizeLogMessage, + afterAdmissionShouldDrop, + onAdmissionCallbackChanged: (callback) => { + pendingPeerBotAdmission = callback; + }, + peerBotAdmissionKey, + mentionAdmissionAccepted: botMentionAdmission === "accepted", dispatchDedupeKeys, ...promptContextBoundaryOptions(promptContextMinTimestampMs), - }); + }; + if (deferStandalonePeerBehindFrames) { + // Wait off the Telegram sequential lane so the framed batch's end can + // arrive, while preserving admission order for this standalone turn. + const spooledReplayParticipant = createSpooledReplayParticipantForBufferedWork( + `peer-standalone:${peerBotAdmissionKey}:${event.msg.message_id}`, + ); + void (async () => { + try { + await processInboundMessage({ + ...processInboundParams, + ...(spooledReplayParticipant ? { spooledReplayParticipant } : {}), + }); + if (spooledReplayParticipant) { + settleSpooledReplayParticipants([spooledReplayParticipant], { kind: "completed" }); + } + } catch (err) { + releaseDispatchDedupeKeys(dispatchDedupeKeys, err); + await afterAdmissionShouldDrop?.(false, false); + if (spooledReplayParticipant) { + settleSpooledReplayParticipants( + [spooledReplayParticipant], + buildFailedProcessingResult(err), + ); + } + runtime.error?.(danger(`${event.errorMessage}: ${String(err)}`)); + } + })(); + return; + } + await processInboundMessage(processInboundParams); } catch (err) { releaseDispatchDedupeKeys(dispatchDedupeKeys, err); + try { + await pendingPeerBotAdmission?.(false, false); + } catch (cleanupErr) { + runtime.error?.( + danger(`${event.errorMessage} admission cleanup failed: ${String(cleanupErr)}`), + ); + } runtime.error?.(danger(`${event.errorMessage}: ${String(err)}`)); if (err instanceof TelegramPairingStoreReadError) { await withTelegramApiErrorLogging({ @@ -3135,6 +3828,11 @@ export const registerTelegramHandlers = ({ if (!msg) { return; } + // Bot-authored message updates can be echoed back by Telegram. Skip them here + // and rely on the dedicated channel_post handler for channel-originated posts. + if (msg.from?.id != null && msg.from.id === ctx.me?.id) { + return; + } const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; const isForum = await resolveTelegramForumFlag({ chatId: msg.chat.id, @@ -3145,11 +3843,6 @@ export const registerTelegramHandlers = ({ getChat, }); const normalizedMsg = withResolvedTelegramForumFlag(msg, isForum); - // Bot-authored message updates can be echoed back by Telegram. Skip them here - // and rely on the dedicated channel_post handler for channel-originated posts. - if (normalizedMsg.from?.id != null && normalizedMsg.from.id === ctx.me?.id) { - return; - } await handleInboundMessageLike({ ctxForDedupe: ctx, ctx: buildSyntheticContext(ctx, normalizedMsg), diff --git a/extensions/telegram/src/bot-message-context.require-mention.test.ts b/extensions/telegram/src/bot-message-context.require-mention.test.ts index 9acc3571d321..86737eb98b9d 100644 --- a/extensions/telegram/src/bot-message-context.require-mention.test.ts +++ b/extensions/telegram/src/bot-message-context.require-mention.test.ts @@ -173,8 +173,10 @@ describe("buildTelegramMessageContext requireMention precedence", () => { }); it("lets explicit topic requireMention=true override always activation", async () => { + const afterAdmissionShouldDrop = vi.fn(async () => false); const ctx = await buildTelegramMessageContextForTest({ message: buildForumMessage(), + options: { afterAdmissionShouldDrop }, resolveGroupActivation: () => false, resolveGroupRequireMention: () => false, resolveTelegramGroupConfig: () => ({ @@ -184,6 +186,44 @@ describe("buildTelegramMessageContext requireMention precedence", () => { }); expect(ctx).toBeNull(); + expect(afterAdmissionShouldDrop).toHaveBeenCalledOnce(); + expect(afterAdmissionShouldDrop).toHaveBeenCalledWith(false); + }); + + it("lets deferred admission suppress an addressed group turn", async () => { + const afterAdmissionShouldDrop = vi.fn(async () => true); + const ctx = await buildTelegramMessageContextForTest({ + message: { ...buildForumMessage(), text: "@bot hello" }, + options: { afterAdmissionShouldDrop }, + resolveGroupActivation: () => false, + resolveGroupRequireMention: () => true, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: true }, + topicConfig: undefined, + }), + }); + + expect(ctx).toBeNull(); + expect(afterAdmissionShouldDrop).toHaveBeenCalledOnce(); + expect(afterAdmissionShouldDrop).toHaveBeenCalledWith(true); + }); + + it("finalizes deferred admission when group policy drops before body admission", async () => { + const afterAdmissionShouldDrop = vi.fn(async () => false); + const ctx = await buildTelegramMessageContextForTest({ + message: buildForumMessage(), + options: { afterAdmissionShouldDrop }, + resolveGroupActivation: () => false, + resolveGroupRequireMention: () => false, + resolveTelegramGroupConfig: () => ({ + groupConfig: { enabled: false }, + topicConfig: undefined, + }), + }); + + expect(ctx).toBeNull(); + expect(afterAdmissionShouldDrop).toHaveBeenCalledOnce(); + expect(afterAdmissionShouldDrop).toHaveBeenCalledWith(false); }); it("keeps activation fallback when no topic requireMention is configured", async () => { diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index 6e65fa825529..6261077fadbf 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -147,6 +147,18 @@ export const buildTelegramMessageContext = async ({ sendChatActionHandler, }: BuildTelegramMessageContextParams): Promise => { const msg = primaryCtx.message; + let admissionFinalized = false; + const finalizeAdmission = async (admitted: boolean): Promise => { + if (admissionFinalized) { + return false; + } + admissionFinalized = true; + return (await options?.afterAdmissionShouldDrop?.(admitted)) ?? false; + }; + const dropBeforeAdmission = async (): Promise => { + await finalizeAdmission(false); + return null; + }; const chatId = msg.chat.id; const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; const senderId = msg.from?.id ? String(msg.from.id) : ""; @@ -270,7 +282,7 @@ export const buildTelegramMessageContext = async ({ reason: "non-default account requires explicit binding", target: route.accountId, }); - return null; + return await dropBeforeAdmission(); } const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom); const dmAllow = await resolveTelegramDmAllow({ @@ -305,27 +317,27 @@ export const buildTelegramMessageContext = async ({ if (!baseAccess.allowed) { if (baseAccess.reason === "group-disabled") { logVerbose(`Blocked telegram group ${chatId} (group disabled)`); - return null; + return await dropBeforeAdmission(); } if (baseAccess.reason === "topic-disabled") { logVerbose( `Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`, ); - return null; + return await dropBeforeAdmission(); } logVerbose( isGroup ? `Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)` : `Blocked telegram DM sender ${senderId || "unknown"} (DM allowFrom override)`, ); - return null; + return await dropBeforeAdmission(); } const requireTopic = directConfig?.requireTopic; const topicRequiredButMissing = !isGroup && requireTopic === true && dmThreadId == null; if (topicRequiredButMissing) { logVerbose(`Blocked telegram DM ${chatId}: requireTopic=true but no topic present`); - return null; + return await dropBeforeAdmission(); } const sendTyping = async () => { @@ -369,7 +381,7 @@ export const buildTelegramMessageContext = async ({ upsertPairingRequest, })) ) { - return null; + return await dropBeforeAdmission(); } let initialTypingCueSent = false; const ensureConfiguredBindingReady = async (): Promise => { @@ -479,13 +491,15 @@ export const buildTelegramMessageContext = async ({ logger, }); if (!bodyResult) { - return null; + return await dropBeforeAdmission(); } if (!(await ensureConfiguredBindingReady())) { + return await dropBeforeAdmission(); + } + if (await finalizeAdmission(true)) { return null; } - // Direct chats are now reply-eligible; send the first typing cue before // expensive context/session construction without showing typing for dropped turns. if (!isGroup) { diff --git a/extensions/telegram/src/bot-message-context.types.ts b/extensions/telegram/src/bot-message-context.types.ts index fdb370aa28bc..2f9fe310ab12 100644 --- a/extensions/telegram/src/bot-message-context.types.ts +++ b/extensions/telegram/src/bot-message-context.types.ts @@ -19,11 +19,13 @@ export type TelegramMediaRef = { }; export type TelegramMessageContextOptions = { + afterAdmissionShouldDrop?: (admitted: boolean, cacheMessage?: boolean) => Promise; commandSource?: "text" | "native"; forceWasMentioned?: boolean; messageIdOverride?: string; receivedAtMs?: number; ingressBuffer?: "inbound-debounce" | "text-fragment"; + promptContextThreadId?: number; promptContextMinTimestampMs?: number; spooledReplay?: boolean; }; diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 034edd698c74..8a31b54ff4c4 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -594,6 +594,288 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.clear).toHaveBeenCalledTimes(1); }); + it("uses observable standard delivery instead of rich drafts for bot-originated turns", async () => { + loadSessionStore.mockReturnValue({ s1: { reasoningLevel: "stream" } }); + deliverInboundReplyWithMessageSendContext.mockResolvedValue({ + status: "handled_visible", + delivery: { messageIds: ["rich"], visibleReplySent: true }, + }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "Ready" }, { kind: "final" }); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { + SessionKey: "s1", + MessageSid: "456", + ReplyToId: "123", + ReplyToBody: "quoted human message", + ReplyToQuoteText: "quoted human message", + ReplyToIsQuote: true, + } as unknown as TelegramMessageContext["ctxPayload"], + msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"], + }), + replyToMode: "first", + telegramCfg: { replyToMode: "first" }, + }); + + expect(createTelegramDraftStream).not.toHaveBeenCalled(); + expect(deliverInboundReplyWithMessageSendContext).toHaveBeenCalledTimes(1); + expect(mockCallArg(deliverInboundReplyWithMessageSendContext)).toMatchObject({ + payload: { + text: "Ready", + replyToId: "456", + replyToIdSource: "implicit", + channelData: { telegram: { standardMessage: true } }, + }, + replyToMode: "first", + }); + expect( + dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0].replyOptions + ?.sourceReplyDeliveryMode, + ).toBeUndefined(); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + + it("defaults bot-originated standard delivery to explicit reply targeting", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "Ready" }, { kind: "final" }); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { MessageSid: "456" } as unknown as TelegramMessageContext["ctxPayload"], + msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"], + }), + }); + + expect(mockCallArg(deliverInboundReplyWithMessageSendContext)).toMatchObject({ + payload: { + text: "Ready", + replyToId: "456", + replyToIdSource: "implicit", + channelData: { telegram: { standardMessage: true } }, + }, + replyToMode: "all", + }); + }); + + it("preserves explicit replyToMode off for bot-originated standard delivery", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "Ready" }, { kind: "final" }); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { MessageSid: "456" } as unknown as TelegramMessageContext["ctxPayload"], + msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"], + }), + replyToMode: "off", + telegramCfg: { replyToMode: "off" }, + }); + + expect(mockCallArg(deliverInboundReplyWithMessageSendContext)).toMatchObject({ + payload: { + text: "Ready", + channelData: { telegram: { standardMessage: true } }, + }, + replyToMode: "off", + }); + expect( + mockCallArg(deliverInboundReplyWithMessageSendContext).payload.replyToId, + ).toBeUndefined(); + }); + + it("preserves explicit reply targets on bot-originated standard delivery", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "Ready", replyToId: "999" }, { kind: "final" }); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { MessageSid: "456" } as unknown as TelegramMessageContext["ctxPayload"], + msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"], + }), + replyToMode: "first", + telegramCfg: { replyToMode: "first" }, + }); + + expect(mockCallArg(deliverInboundReplyWithMessageSendContext).payload).toMatchObject({ + text: "Ready", + replyToId: "999", + }); + }); + + it("consumes queued peer-bot implicit targets across payloads", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ queuedFinal: true }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { MessageSid: "456" } as unknown as TelegramMessageContext["ctxPayload"], + msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"], + }), + replyToMode: "first", + telegramCfg: { replyToMode: "first" }, + }); + + const transform = + dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0].replyOptions + ?.queuedDeliveryPayloadTransform; + const didDeliver = + dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0].replyOptions + ?.queuedDeliveryPayloadDidDeliver; + expect( + dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0].replyOptions + ?.queuedDeliveryReplyToMode, + ).toBe("first"); + expect(transform).toBeTypeOf("function"); + expect(didDeliver).toBeTypeOf("function"); + const first = transform?.({ text: "first" }); + expect(first).toMatchObject({ + replyToId: "456", + replyToIdSource: "implicit", + }); + expect(transform?.({ text: "not-yet-delivered" })).toMatchObject({ replyToId: "456" }); + if (first) { + didDeliver?.(first); + } + expect(transform?.({ text: "second" })?.replyToId).toBeUndefined(); + expect(transform?.({ text: "explicit", replyToId: "999" })?.replyToId).toBe("999"); + }); + + it("does not consume a peer reply target before visible delivery", async () => { + deliverInboundReplyWithMessageSendContext + .mockResolvedValueOnce({ status: "handled_no_send", reason: "no_visible_result" }) + .mockResolvedValueOnce({ + status: "handled_visible", + delivery: { messageIds: ["visible"], visibleReplySent: true }, + }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "suppressed" }, { kind: "final" }); + await dispatcherOptions.deliver({ text: "visible" }, { kind: "final" }); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { MessageSid: "456" } as unknown as TelegramMessageContext["ctxPayload"], + msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"], + }), + replyToMode: "first", + telegramCfg: { replyToMode: "first" }, + }); + + expect(deliverInboundReplyWithMessageSendContext).toHaveBeenCalledTimes(2); + expect(deliverInboundReplyWithMessageSendContext.mock.calls[1]?.[0].payload).toMatchObject({ + text: "visible", + replyToId: "456", + replyToIdSource: "implicit", + }); + }); + + it("consumes a peer reply target after a partially visible durable failure", async () => { + const partialError = new Error("second chunk failed"); + deliverInboundReplyWithMessageSendContext + .mockResolvedValueOnce({ + status: "failed", + error: partialError, + sentBeforeError: true, + }) + .mockResolvedValueOnce({ + status: "handled_visible", + delivery: { messageIds: ["visible"], visibleReplySent: true }, + }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await expect(dispatcherOptions.deliver({ text: "partial" }, { kind: "final" })).rejects.toBe( + partialError, + ); + await dispatcherOptions.deliver({ text: "later" }, { kind: "final" }); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { MessageSid: "456" } as unknown as TelegramMessageContext["ctxPayload"], + msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"], + }), + replyToMode: "first", + telegramCfg: { replyToMode: "first" }, + }); + + expect(deliverInboundReplyWithMessageSendContext).toHaveBeenCalledTimes(2); + expect( + deliverInboundReplyWithMessageSendContext.mock.calls[1]?.[0].payload.replyToId, + ).toBeUndefined(); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + + it("does not send a fallback after a partially visible durable failure", async () => { + deliverInboundReplyWithMessageSendContext.mockResolvedValueOnce({ + status: "failed", + error: new Error("second chunk failed"), + sentBeforeError: true, + }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "partial" }, { kind: "final" }); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { MessageSid: "456" } as unknown as TelegramMessageContext["ctxPayload"], + msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"], + }), + replyToMode: "first", + telegramCfg: { replyToMode: "first" }, + }); + + expect(deliverReplies).not.toHaveBeenCalled(); + }); + + it("does not send a fallback after a partially visible direct failure", async () => { + const partialError = Object.assign(new Error("second direct chunk failed"), { + sentBeforeError: true, + visibleReplySent: true, + }); + deliverReplies.mockRejectedValueOnce(partialError); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "partial" }, { kind: "final" }); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { MessageSid: "456" } as unknown as TelegramMessageContext["ctxPayload"], + msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"], + }), + replyToMode: "first", + telegramCfg: { replyToMode: "first" }, + }); + + expect(deliverReplies).toHaveBeenCalledTimes(1); + }); + + it("sends a terminal failure fallback after progress-only delivery", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "working" }, { kind: "tool" }); + throw new Error("agent failed after progress"); + }); + + await dispatchWithContext({ context: createContext() }); + + expect(deliverReplies).toHaveBeenCalledTimes(2); + expect(deliverReplies.mock.calls[1]?.[0].replies).toEqual([ + expect.objectContaining({ + text: "Something went wrong while processing your request. Please try again.", + }), + ]); + }); + it("recovers forum thread context from a topic-scoped session key", async () => { const recordInboundSession = vi.fn(async () => undefined); const oldHistoryKey = "-1003774691294:topic:1"; diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 22b1ad7527f9..2d25413f4b08 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -47,6 +47,7 @@ import { resolveSendableOutboundReplyParts, } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload"; +import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference"; import type { BlockReplyContext } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { @@ -99,6 +100,7 @@ import { } from "./bot/native-quote.js"; import type { TelegramStreamMode } from "./bot/types.js"; import { resolveTelegramInlineButtons, type TelegramInlineButtons } from "./button-types.js"; +import { isTelegramDeliveryErrorVisible } from "./delivery-error.js"; import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js"; import { createTelegramDraftStream } from "./draft-stream.js"; import { @@ -117,6 +119,7 @@ import { type LaneName, } from "./lane-delivery.js"; import { recordOutboundMessageForPromptContext } from "./outbound-message-context.js"; +import { runWithTelegramPeerBotTurn, type TelegramPeerBotTurn } from "./peer-bot-turn.js"; import { createTelegramReasoningStepState, splitTelegramReasoningText, @@ -215,7 +218,7 @@ type DispatchTelegramMessageParams = { textLimit: number; telegramCfg: TelegramAccountConfig; telegramDeps?: TelegramBotDeps; - opts: Pick; + opts: Pick; retryDispatchErrors?: boolean; suppressFailureFallback?: boolean; }; @@ -840,8 +843,14 @@ export const dispatchTelegramMessage = async ({ replyQuoteText && !ctxPayload.ReplyToIsExternal ? resolveTelegramReplyId(ctxPayload.ReplyToId) : undefined; + const inboundFromBot = msg.from?.is_bot === true && msg.sender_chat == null; + // Peer-bot visibility is the intentional exception to Telegram's human-turn + // reply default: unset targets every peer fragment; explicit "off" still wins. + const effectiveReplyToMode = inboundFromBot + ? (opts.replyToMode ?? telegramCfg.replyToMode ?? "all") + : replyToMode; const replyQuoteByMessageId: TelegramNativeQuoteCandidateByMessageId = {}; - if (replyToMode !== "off") { + if (effectiveReplyToMode !== "off") { if (replyQuoteText && replyQuoteMessageId != null) { addTelegramNativeQuoteCandidate(replyQuoteByMessageId, replyQuoteMessageId, { text: replyQuoteText, @@ -873,18 +882,19 @@ export const dispatchTelegramMessage = async ({ ); } } - const hasTelegramQuoteReply = replyToMode !== "off" && replyQuoteText != null; + const hasTelegramQuoteReply = effectiveReplyToMode !== "off" && replyQuoteText != null; const canStreamAnswerDraft = streamDeliveryEnabled && + !inboundFromBot && !hasTelegramQuoteReply && !accountBlockStreamingEnabled && !forceBlockStreamingForReasoning; const streamReasoningInProgressDraft = streamReasoningDraft && streamMode === "progress" && canStreamAnswerDraft; const canStreamReasoningDraft = - !isRoomEvent && streamReasoningDraft && !streamReasoningInProgressDraft; + !isRoomEvent && !inboundFromBot && streamReasoningDraft && !streamReasoningInProgressDraft; const draftReplyToMessageId = - replyToMode !== "off" && typeof msg.message_id === "number" + effectiveReplyToMode !== "off" && typeof msg.message_id === "number" ? (replyQuoteMessageId ?? msg.message_id) : undefined; const draftMinInitialChars = streamMode === "progress" ? 0 : DRAFT_MIN_INITIAL_CHARS; @@ -1301,15 +1311,17 @@ export const dispatchTelegramMessage = async ({ }; const resolvedBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(telegramCfg); - const disableBlockStreaming = !streamDeliveryEnabled + const disableBlockStreaming = inboundFromBot ? true - : forceBlockStreamingForReasoning - ? false - : typeof resolvedBlockStreamingEnabled === "boolean" - ? !resolvedBlockStreamingEnabled - : canStreamAnswerDraft - ? true - : undefined; + : !streamDeliveryEnabled + ? true + : forceBlockStreamingForReasoning + ? false + : typeof resolvedBlockStreamingEnabled === "boolean" + ? !resolvedBlockStreamingEnabled + : canStreamAnswerDraft + ? true + : undefined; const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId); @@ -1342,6 +1354,7 @@ export const dispatchTelegramMessage = async ({ ? ctxPayload.ReplyToQuoteEntities : undefined; const deliveryState = createLaneDeliveryStateTracker(); + let terminalReplyVisible = false; const clearGroupHistory = () => { if (isGroup && historyKey) { createChannelHistoryWindow({ historyMap: groupHistories }).clear({ @@ -1410,11 +1423,14 @@ export const dispatchTelegramMessage = async ({ bot, mediaLocalRoots, mediaMaxBytes: (opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 100) * 1024 * 1024, - replyToMode, + replyToMode: effectiveReplyToMode, textLimit, thread: threadSpec, tableMode, chunkMode, + // Rich draft messages are not emitted as updates to peer bots. Bot-originated + // turns must use standard messages so automation can observe the reply. + standardMessages: inboundFromBot, linkPreview: telegramCfg.linkPreview, replyQuoteMessageId, replyQuoteText, @@ -1441,6 +1457,28 @@ export const dispatchTelegramMessage = async ({ let hadErrorReplyFailureOrSkip = false; let isFirstTurnInSession = false; let dispatchError: unknown; + let peerImplicitReplyAvailable = true; + const applyPeerImplicitReply = (payload: ReplyPayload): ReplyPayload => { + if ( + !inboundFromBot || + effectiveReplyToMode === "off" || + !ctxPayload.MessageSid || + payload.replyToId != null || + (isSingleUseReplyToMode(effectiveReplyToMode) && !peerImplicitReplyAvailable) + ) { + return payload; + } + return { + ...payload, + replyToId: ctxPayload.MessageSid, + replyToIdSource: "implicit", + }; + }; + const commitPeerImplicitReply = (payload: ReplyPayload) => { + if (payload.replyToIdSource === "implicit" && isSingleUseReplyToMode(effectiveReplyToMode)) { + peerImplicitReplyAvailable = false; + } + }; try { const sticker = ctxPayload.Sticker; @@ -1528,6 +1566,19 @@ export const dispatchTelegramMessage = async ({ return false; } const deliverablePayload = applyQuoteReplyTarget(payload); + const addressedPayload = applyPeerImplicitReply(deliverablePayload); + const transportPayload = inboundFromBot + ? { + ...addressedPayload, + channelData: { + ...addressedPayload.channelData, + telegram: { + ...(addressedPayload.channelData?.telegram as Record | undefined), + standardMessage: true, + }, + }, + } + : addressedPayload; const silent = options?.silent ?? (silentErrorReplies && payload.isError === true); const durableDelivery = telegramDeps.deliverInboundReplyWithMessageSendContext; if (options?.durable && durableDelivery) { @@ -1538,9 +1589,9 @@ export const dispatchTelegramMessage = async ({ accountId: route.accountId, agentId: route.agentId, ctxPayload, - payload: deliverablePayload, + payload: transportPayload, info: { kind: "final" }, - replyToMode, + replyToMode: effectiveReplyToMode, threadId: threadSpec.id, formatting: { textLimit, @@ -1549,8 +1600,8 @@ export const dispatchTelegramMessage = async ({ }, silent, requiredCapabilities: deriveDurableFinalDeliveryRequirements({ - payload: deliverablePayload, - replyToId: deliverablePayload.replyToId, + payload: transportPayload, + replyToId: transportPayload.replyToId, threadId: threadSpec.id, silent, payloadTransport: true, @@ -1560,26 +1611,49 @@ export const dispatchTelegramMessage = async ({ }), }); if (durable.status === "failed") { + if (durable.sentBeforeError) { + commitPeerImplicitReply(transportPayload); + deliveryState.markDelivered(); + terminalReplyVisible = true; + } throw durable.error; } if (durable.status === "handled_visible") { + commitPeerImplicitReply(transportPayload); deliveryState.markDelivered(); + terminalReplyVisible = true; return true; } if (durable.status === "handled_no_send") { return false; } } - const result = await (telegramDeps.deliverReplies ?? deliverReplies)({ - ...deliveryBaseOptions, - transcriptMirror: options?.durable ? deliveryBaseOptions.transcriptMirror : undefined, - replies: [deliverablePayload], - onVoiceRecording: sendRecordVoice, - silent, - mediaLoader: telegramDeps.loadWebMedia, - }); + let result: Awaited>; + try { + result = await (telegramDeps.deliverReplies ?? deliverReplies)({ + ...deliveryBaseOptions, + transcriptMirror: options?.durable ? deliveryBaseOptions.transcriptMirror : undefined, + replies: [transportPayload], + onVoiceRecording: sendRecordVoice, + silent, + mediaLoader: telegramDeps.loadWebMedia, + }); + } catch (error) { + if (isTelegramDeliveryErrorVisible(error)) { + commitPeerImplicitReply(transportPayload); + deliveryState.markDelivered(); + if (options?.durable) { + terminalReplyVisible = true; + } + } + throw error; + } if (result.delivered) { + commitPeerImplicitReply(transportPayload); deliveryState.markDelivered(); + if (options?.durable) { + terminalReplyVisible = true; + } } return result.delivered; }; @@ -1812,306 +1886,372 @@ export const dispatchTelegramMessage = async ({ }); try { - const turnResult = await runChannelInboundEvent({ - channel: "telegram", - accountId: route.accountId, - raw: dispatchContext, - adapter: { - ingest: () => ({ - id: ctxPayload.MessageSid ?? `${chatId}:${Date.now()}`, - timestamp: typeof ctxPayload.Timestamp === "number" ? ctxPayload.Timestamp : undefined, - rawText: ctxPayload.RawBody ?? "", - textForAgent: ctxPayload.BodyForAgent, - textForCommands: ctxPayload.CommandBody, - raw: dispatchContext, - }), - resolveTurn: () => ({ - channel: "telegram", - accountId: route.accountId, - routeSessionKey: route.sessionKey, - storePath: dispatchContext.turn.storePath, - ctxPayload, - recordInboundSession: dispatchContext.turn.recordInboundSession, - record: dispatchContext.turn.record, - runDispatch: () => { - const sentBlockMediaUrls = new Set(); + const canonicalReplyMessageId = Number(ctxPayload.MessageSid); + const peerBotTurn: TelegramPeerBotTurn | undefined = + inboundFromBot && msg.from?.id != null + ? { + accountId: route.accountId, + chatAliases: [msg.chat.username] + .filter((value): value is string => Boolean(value)) + .map((value) => `@${value}`), + chatId: String(chatId), + messageId: + Number.isInteger(canonicalReplyMessageId) && canonicalReplyMessageId > 0 + ? canonicalReplyMessageId + : msg.message_id, + senderAliases: [msg.from?.username] + .filter((value): value is string => Boolean(value)) + .map((value) => `@${value}`), + senderId: String(msg.from.id), + ...(threadSpec.id != null ? { threadId: threadSpec.id } : {}), + } + : undefined; + const transformQueuedPeerBotPayload = (payload: ReplyPayload): ReplyPayload => { + const addressedPayload = applyPeerImplicitReply(payload); + return { + ...addressedPayload, + channelData: { + ...addressedPayload.channelData, + telegram: { + ...(addressedPayload.channelData?.telegram as Record | undefined), + standardMessage: true, + }, + }, + }; + }; + const runInboundTurn = async () => + await runChannelInboundEvent({ + channel: "telegram", + accountId: route.accountId, + raw: dispatchContext, + adapter: { + ingest: () => ({ + id: ctxPayload.MessageSid ?? `${chatId}:${Date.now()}`, + timestamp: + typeof ctxPayload.Timestamp === "number" ? ctxPayload.Timestamp : undefined, + rawText: ctxPayload.RawBody ?? "", + textForAgent: ctxPayload.BodyForAgent, + textForCommands: ctxPayload.CommandBody, + raw: dispatchContext, + }), + resolveTurn: () => ({ + channel: "telegram", + accountId: route.accountId, + routeSessionKey: route.sessionKey, + storePath: dispatchContext.turn.storePath, + ctxPayload, + recordInboundSession: dispatchContext.turn.recordInboundSession, + record: dispatchContext.turn.record, + runDispatch: () => { + const sentBlockMediaUrls = new Set(); - return telegramDeps.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg, - dispatcherOptions: { - ...replyPipeline, - beforeDeliver: async (payload) => payload, - onBeforeDeliverCancelled: (payload, info) => { - if (info.kind === "block") { - return enqueueDraftLaneEvent(async () => { - dropQueuedAnswerBlockRotation(payload, info.assistantMessageIndex); - }); - } - return undefined; - }, - deliver: async (payload, info) => { - if (isDispatchSuperseded()) { - return; - } + return telegramDeps.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg, + dispatcherOptions: { + ...replyPipeline, + beforeDeliver: async (payload) => payload, + onBeforeDeliverCancelled: (payload, info) => { + if (info.kind === "block") { + return enqueueDraftLaneEvent(async () => { + dropQueuedAnswerBlockRotation(payload, info.assistantMessageIndex); + }); + } + return undefined; + }, + deliver: async (payload, info) => { + if (isDispatchSuperseded()) { + return; + } - const deduped = - info.kind === "final" - ? deduplicateBlockSentMedia(payload, sentBlockMediaUrls) - : payload; - if (deduped === undefined) { - return; - } - const effectivePayload = deduped; + const deduped = + info.kind === "final" + ? deduplicateBlockSentMedia(payload, sentBlockMediaUrls) + : payload; + if (deduped === undefined) { + return; + } + const effectivePayload = deduped; - if ( - shouldSuppressLocalTelegramExecApprovalPrompt({ - cfg, - accountId: route.accountId, - payload, - }) - ) { - queuedFinal = true; - return; - } - const telegramButtons = resolvePayloadTelegramInlineButtons(effectivePayload); - const split = splitTextIntoLaneSegments( - { text: effectivePayload.text }, - payload.isReasoning, - ); - const segments = split.segments; - const reply = resolveSendableOutboundReplyParts(effectivePayload); - if (info.kind === "final" && (reply.text.length > 0 || reply.hasMedia)) { - markProgressFinalStarted(); - } - if (info.kind === "final") { - await enqueueDraftLaneEvent(async () => {}); - } - // Hide handled post-answer probe failures while preserving final warnings. - // Agents may intentionally run searches/commands with no result, recover, - // and send a final answer; late text-only failures are non-actionable noise. - const isToolPayloadAfterFinal = - info.kind === "tool" && (finalAnswerDeliveryStarted || finalAnswerDelivered); - const isNonTerminalWarningAfterDeliveredFinal = - isReplyPayloadNonTerminalToolErrorWarning(effectivePayload) && - finalAnswerDelivered; - if ( - (isToolPayloadAfterFinal || isNonTerminalWarningAfterDeliveredFinal) && - !reply.hasMedia && - !hasExecApprovalPayload(effectivePayload) - ) { - return; - } - if (payload.isError === true) { - hadErrorReplyFailureOrSkip = true; - } + if ( + shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg, + accountId: route.accountId, + payload, + }) + ) { + queuedFinal = true; + return; + } + const telegramButtons = resolvePayloadTelegramInlineButtons(effectivePayload); + const split = splitTextIntoLaneSegments( + { text: effectivePayload.text }, + payload.isReasoning, + ); + const segments = split.segments; + const reply = resolveSendableOutboundReplyParts(effectivePayload); + if (info.kind === "final" && (reply.text.length > 0 || reply.hasMedia)) { + markProgressFinalStarted(); + } + if (info.kind === "final") { + await enqueueDraftLaneEvent(async () => {}); + } + // Hide handled post-answer probe failures while preserving final warnings. + // Agents may intentionally run searches/commands with no result, recover, + // and send a final answer; late text-only failures are non-actionable noise. + const isToolPayloadAfterFinal = + info.kind === "tool" && + (finalAnswerDeliveryStarted || finalAnswerDelivered); + const isNonTerminalWarningAfterDeliveredFinal = + isReplyPayloadNonTerminalToolErrorWarning(effectivePayload) && + finalAnswerDelivered; + if ( + (isToolPayloadAfterFinal || isNonTerminalWarningAfterDeliveredFinal) && + !reply.hasMedia && + !hasExecApprovalPayload(effectivePayload) + ) { + return; + } + if (payload.isError === true) { + hadErrorReplyFailureOrSkip = true; + } - const deliverFinalAnswerText = async ( - answerPayload: ReplyPayload, - text: string, - buttons?: TelegramInlineButtons, - ) => { - const finalText = await resolveTranscriptBackedFinalText(text); - const deliverPostFinalFollowUpText = async () => { - await prepareAnswerLaneForText(); - return deliverLaneText({ + const deliverFinalAnswerText = async ( + answerPayload: ReplyPayload, + text: string, + buttons?: TelegramInlineButtons, + ) => { + const finalText = await resolveTranscriptBackedFinalText(text); + const deliverPostFinalFollowUpText = async () => { + await prepareAnswerLaneForText(); + return deliverLaneText({ + laneName: "answer", + text: finalText, + payload: answerPayload, + infoKind: "final", + buttons, + }); + }; + if (finalAnswerDelivered) { + return deliverPostFinalFollowUpText(); + } + if (streamMode === "progress") { + return deliverProgressModeFinalAnswer(answerPayload, finalText); + } + if (!(await rotateAnswerLaneAfterToolProgress())) { + await rotateAnswerLaneAfterQueuedBlocksSettle(); + } + const result = await deliverLaneText({ laneName: "answer", text: finalText, payload: answerPayload, infoKind: "final", buttons, }); + if (result.kind !== "skipped") { + markProgressFinalDelivered(); + } + return result; }; - if (finalAnswerDelivered) { - return deliverPostFinalFollowUpText(); - } - if (streamMode === "progress") { - return deliverProgressModeFinalAnswer(answerPayload, finalText); - } - if (!(await rotateAnswerLaneAfterToolProgress())) { - await rotateAnswerLaneAfterQueuedBlocksSettle(); - } - const result = await deliverLaneText({ - laneName: "answer", - text: finalText, - payload: answerPayload, - infoKind: "final", - buttons, - }); - if (result.kind !== "skipped") { - markProgressFinalDelivered(); - } - return result; - }; - const flushBufferedFinalAnswer = async () => { - const buffered = - reasoningStepState.takeBufferedFinalAnswer(replyFenceGeneration); - if (!buffered) { - return; - } - const bufferedButtons = resolvePayloadTelegramInlineButtons(buffered.payload); - await deliverFinalAnswerText( - buffered.payload, - buffered.text, - bufferedButtons, + const flushBufferedFinalAnswer = async () => { + const buffered = + reasoningStepState.takeBufferedFinalAnswer(replyFenceGeneration); + if (!buffered) { + return; + } + const bufferedButtons = resolvePayloadTelegramInlineButtons( + buffered.payload, + ); + await deliverFinalAnswerText( + buffered.payload, + buffered.text, + bufferedButtons, + ); + reasoningStepState.resetForNextStep(); + }; + + let blockDelivered = false; + const hasAnswerSegment = segments.some( + (segment) => segment.lane === "answer", ); - reasoningStepState.resetForNextStep(); - }; + if (info.kind === "block" && !hasAnswerSegment) { + dropQueuedAnswerBlockRotation(effectivePayload, info.assistantMessageIndex); + } + for (const segment of segments) { + if ( + segment.lane === "answer" && + info.kind === "final" && + reasoningStepState.shouldBufferFinalAnswer() + ) { + reasoningStepState.bufferFinalAnswer({ + payload: effectivePayload, + text: segment.update.text, + bufferedGeneration: replyFenceGeneration, + }); + continue; + } + if (segment.lane === "reasoning") { + reasoningStepState.noteReasoningHint(); + } + if (segment.lane === "answer" && info.kind === "tool") { + if (verboseProgressActive()) { + // Durable lane owns tool payloads: send standalone instead + // of diverting into the draft, which is discarded at final. + if ( + await sendPayload( + applyTextToPayload(effectivePayload, segment.update.text), + ) + ) { + blockDelivered = true; + } + continue; + } + if (streamMode === "progress" && answerLane.stream) { + // Progress-mode streams render tool status in the + // live draft. Do not also emit text-only tool output + // as answer text, or simple commands duplicate and + // restart the progress draft. + continue; + } + await prepareAnswerLaneForToolProgress(); + } - let blockDelivered = false; - const hasAnswerSegment = segments.some((segment) => segment.lane === "answer"); - if (info.kind === "block" && !hasAnswerSegment) { - dropQueuedAnswerBlockRotation(effectivePayload, info.assistantMessageIndex); - } - for (const segment of segments) { - if ( - segment.lane === "answer" && - info.kind === "final" && - reasoningStepState.shouldBufferFinalAnswer() - ) { - reasoningStepState.bufferFinalAnswer({ - payload: effectivePayload, - text: segment.update.text, - bufferedGeneration: replyFenceGeneration, - }); - continue; - } - if (segment.lane === "reasoning") { - reasoningStepState.noteReasoningHint(); - } - if (segment.lane === "answer" && info.kind === "tool") { - if (verboseProgressActive()) { - // Durable lane owns tool payloads: send standalone instead - // of diverting into the draft, which is discarded at final. - if ( - await sendPayload( - applyTextToPayload(effectivePayload, segment.update.text), - ) - ) { - blockDelivered = true; + const ownedByQueuedAnswerBlockRotation = queuedAnswerBlockRotations.some( + (entry) => + queuedAnswerBlockRotationMatchesDelivery( + entry, + effectivePayload, + info.assistantMessageIndex, + ), + ); + + const skipTextOnlyBlock = + streamMode === "partial" && + info.kind === "block" && + segment.lane === "answer" && + !reply.hasMedia && + !hasExecApprovalPayload(effectivePayload) && + telegramButtons === undefined && + answerLane.hasStreamedMessage && + !activeAnswerDraftIsToolProgressOnly && + !ownedByQueuedAnswerBlockRotation && + segment.update.text.trimEnd() === answerLane.lastPartialText.trimEnd(); + + if (skipTextOnlyBlock) { + // Keep duplicate blocks available for later rotation/finalization. + skippedDuplicateAnswerBlockDraftDelivery = true; + lastAnswerBlockPayload = effectivePayload; + lastAnswerBlockText = segment.update.text; + lastAnswerBlockButtons = telegramButtons; + resetAnswerToolProgressDraft(); + resetProgressDraftState(); + blockDelivered = true; + continue; + } + + if (segment.lane === "answer" && info.kind === "block") { + const preparedAnswerLane = await prepareAnswerLaneForText(); + const shouldRotateQueuedBlock = takeQueuedAnswerBlockRotation( + effectivePayload, + info.assistantMessageIndex, + ); + if (shouldRotateQueuedBlock && !preparedAnswerLane) { + await rotateAnswerLaneForNewMessage(); + rotateAnswerLaneWhenQueuedBlocksSettle = false; + } + resetAnswerToolProgressDraft(); + resetProgressDraftState(); + } + const result = + segment.lane === "answer" && info.kind === "final" + ? await deliverFinalAnswerText( + effectivePayload, + segment.update.text, + telegramButtons, + ) + : await deliverLaneText({ + laneName: segment.lane, + text: segment.update.text, + payload: effectivePayload, + infoKind: info.kind, + buttons: telegramButtons, + }); + if (segment.lane === "answer" && result.kind === "preview-finalized") { + await emitPreviewFinalizedHook(result); + } + if ( + segment.lane === "answer" && + info.kind === "block" && + (result.kind === "preview-updated" || + result.kind === "preview-finalized" || + result.kind === "preview-retained") + ) { + lastAnswerBlockPayload = effectivePayload; + lastAnswerBlockText = segment.update.text; + lastAnswerBlockButtons = telegramButtons; + } + blockDelivered = blockDelivered || result.kind !== "skipped"; + if (segment.lane === "reasoning") { + if (result.kind !== "skipped") { + reasoningStepState.noteReasoningDelivered(); + await flushBufferedFinalAnswer(); } continue; } - if (streamMode === "progress" && answerLane.stream) { - // Progress-mode streams render tool status in the - // live draft. Do not also emit text-only tool output - // as answer text, or simple commands duplicate and - // restart the progress draft. - continue; + if (info.kind === "final") { + reasoningStepState.resetForNextStep(); } - await prepareAnswerLaneForToolProgress(); } - - const ownedByQueuedAnswerBlockRotation = queuedAnswerBlockRotations.some( - (entry) => - queuedAnswerBlockRotationMatchesDelivery( - entry, - effectivePayload, - info.assistantMessageIndex, - ), - ); - - const skipTextOnlyBlock = - streamMode === "partial" && - info.kind === "block" && - segment.lane === "answer" && - !reply.hasMedia && - !hasExecApprovalPayload(effectivePayload) && - telegramButtons === undefined && - answerLane.hasStreamedMessage && - !activeAnswerDraftIsToolProgressOnly && - !ownedByQueuedAnswerBlockRotation && - segment.update.text.trimEnd() === answerLane.lastPartialText.trimEnd(); - - if (skipTextOnlyBlock) { - // Keep duplicate blocks available for later rotation/finalization. - skippedDuplicateAnswerBlockDraftDelivery = true; - lastAnswerBlockPayload = effectivePayload; - lastAnswerBlockText = segment.update.text; - lastAnswerBlockButtons = telegramButtons; - resetAnswerToolProgressDraft(); - resetProgressDraftState(); - blockDelivered = true; - continue; - } - - if (segment.lane === "answer" && info.kind === "block") { - const preparedAnswerLane = await prepareAnswerLaneForText(); - const shouldRotateQueuedBlock = takeQueuedAnswerBlockRotation( - effectivePayload, - info.assistantMessageIndex, - ); - if (shouldRotateQueuedBlock && !preparedAnswerLane) { - await rotateAnswerLaneForNewMessage(); - rotateAnswerLaneWhenQueuedBlocksSettle = false; + const trackBlockMedia = (delivered: boolean) => { + if (delivered && info.kind === "block" && payload.mediaUrls?.length) { + for (const url of payload.mediaUrls) { + sentBlockMediaUrls.add(url); + } } - resetAnswerToolProgressDraft(); - resetProgressDraftState(); + }; + + if (segments.length > 0) { + trackBlockMedia(blockDelivered); + return; } - const result = - segment.lane === "answer" && info.kind === "final" - ? await deliverFinalAnswerText( - effectivePayload, - segment.update.text, - telegramButtons, - ) - : await deliverLaneText({ - laneName: segment.lane, - text: segment.update.text, - payload: effectivePayload, - infoKind: info.kind, - buttons: telegramButtons, - }); - if (segment.lane === "answer" && result.kind === "preview-finalized") { - await emitPreviewFinalizedHook(result); - } - if ( - segment.lane === "answer" && - info.kind === "block" && - (result.kind === "preview-updated" || - result.kind === "preview-finalized" || - result.kind === "preview-retained") - ) { - lastAnswerBlockPayload = effectivePayload; - lastAnswerBlockText = segment.update.text; - lastAnswerBlockButtons = telegramButtons; - } - blockDelivered = blockDelivered || result.kind !== "skipped"; - if (segment.lane === "reasoning") { - if (result.kind !== "skipped") { - reasoningStepState.noteReasoningDelivered(); + if (split.suppressedReasoningOnly) { + let delivered = false; + if (reply.hasMedia) { + const payloadWithoutSuppressedReasoning = + typeof effectivePayload.text === "string" + ? { ...effectivePayload, text: "" } + : effectivePayload; + delivered = await sendPayload(payloadWithoutSuppressedReasoning, { + durable: info.kind === "final", + }); + } + if (info.kind === "final" && delivered) { + markProgressFinalDelivered(); + } + if (info.kind === "final") { await flushBufferedFinalAnswer(); } - continue; + trackBlockMedia(delivered); + return; } + if (info.kind === "final") { + await rotateAnswerLaneAfterToolProgress(); + await answerLane.stream?.stop(); + await reasoningLane.stream?.stop(); reasoningStepState.resetForNextStep(); } - } - const trackBlockMedia = (delivered: boolean) => { - if (delivered && info.kind === "block" && payload.mediaUrls?.length) { - for (const url of payload.mediaUrls) { - sentBlockMediaUrls.add(url); + const canSendAsIs = reply.hasMedia || reply.text.length > 0; + if (!canSendAsIs) { + if (info.kind === "final") { + await flushBufferedFinalAnswer(); } + return; } - }; - - if (segments.length > 0) { - trackBlockMedia(blockDelivered); - return; - } - if (split.suppressedReasoningOnly) { - let delivered = false; - if (reply.hasMedia) { - const payloadWithoutSuppressedReasoning = - typeof effectivePayload.text === "string" - ? { ...effectivePayload, text: "" } - : effectivePayload; - delivered = await sendPayload(payloadWithoutSuppressedReasoning, { - durable: info.kind === "final", - }); - } + const delivered = await sendPayload(effectivePayload, { + durable: info.kind === "final", + }); if (info.kind === "final" && delivered) { markProgressFinalDelivered(); } @@ -2119,290 +2259,281 @@ export const dispatchTelegramMessage = async ({ await flushBufferedFinalAnswer(); } trackBlockMedia(delivered); - return; - } - - if (info.kind === "final") { - await rotateAnswerLaneAfterToolProgress(); - await answerLane.stream?.stop(); - await reasoningLane.stream?.stop(); - reasoningStepState.resetForNextStep(); - } - const canSendAsIs = reply.hasMedia || reply.text.length > 0; - if (!canSendAsIs) { - if (info.kind === "final") { - await flushBufferedFinalAnswer(); + }, + onSkip: (payload, info) => { + if (info.kind === "block") { + void enqueueDraftLaneEvent(async () => { + dropQueuedAnswerBlockRotation(payload, info.assistantMessageIndex); + }); } - return; - } - const delivered = await sendPayload(effectivePayload, { - durable: info.kind === "final", - }); - if (info.kind === "final" && delivered) { - markProgressFinalDelivered(); - } - if (info.kind === "final") { - await flushBufferedFinalAnswer(); - } - trackBlockMedia(delivered); - }, - onSkip: (payload, info) => { - if (info.kind === "block") { - void enqueueDraftLaneEvent(async () => { - dropQueuedAnswerBlockRotation(payload, info.assistantMessageIndex); + if (payload.isError === true) { + hadErrorReplyFailureOrSkip = true; + } + if (info.reason !== "silent") { + deliveryState.markNonSilentSkip(); + } + }, + onError: (err, info) => { + const errorPolicy = resolveTelegramErrorPolicy({ + accountConfig: telegramCfg, + groupConfig, + topicConfig, }); - } - if (payload.isError === true) { - hadErrorReplyFailureOrSkip = true; - } - if (info.reason !== "silent") { - deliveryState.markNonSilentSkip(); - } - }, - onError: (err, info) => { - const errorPolicy = resolveTelegramErrorPolicy({ - accountConfig: telegramCfg, - groupConfig, - topicConfig, - }); - if (isSilentErrorPolicy(errorPolicy.policy)) { - return; - } - if ( - errorPolicy.policy === "once" && - shouldSuppressTelegramError({ - scopeKey: buildTelegramErrorScopeKey({ - accountId: route.accountId, - chatId, - threadId: threadSpec.id, - }), - cooldownMs: errorPolicy.cooldownMs, - errorMessage: String(err), - }) - ) { - return; - } - deliveryState.markNonSilentFailure(); - runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); - }, - }, - replyOptions: { - skillFilter, - disableBlockStreaming, - abortSignal: replyAbortController.signal, - sourceReplyDeliveryMode: isRoomEvent ? "message_tool_only" : undefined, - queuedDeliveryCorrelations: isRoomEvent - ? [{ begin: beginDeliveryCorrelation }] - : undefined, - queuedFollowupLifecycle: isRoomEvent - ? { - onEnqueued: () => { - replyAbortControllerQueued = true; - }, - onComplete: () => { - replyAbortControllerQueued = false; - releaseTelegramReplyFenceAbortController( - activeReplyFenceKey, - replyAbortController, - ); - }, - } - : undefined, - suppressTyping: isRoomEvent, - onPartialReply: - answerLane.stream || reasoningLane.stream - ? (payload) => - enqueueDraftLaneEvent(async () => { - await ingestDraftLaneSegments(payload); - }) - : undefined, - onBlockReplyQueued: answerLane.stream - ? (payload, blockContext) => - enqueueDraftLaneEvent(async () => { - await prepareQueuedAnswerBlock(payload, blockContext); - }) - : undefined, - onReasoningStream: reasoningLane.stream - ? (payload) => - enqueueDraftLaneEvent(async () => { - if (splitReasoningOnNextStream) { - reasoningLane.stream?.forceNewMessage(); - resetDraftLaneState(reasoningLane); - splitReasoningOnNextStream = false; - } - await ingestDraftLaneSegments(payload, true); - }) - : streamReasoningInProgressDraft - ? (payload) => - enqueueDraftLaneEvent(async () => { - await pushStreamReasoningProgress(payload); - }) - : undefined, - onAssistantMessageStart: answerLane.stream - ? () => - enqueueDraftLaneEvent(async () => { - reasoningStepState.resetForNextStep(); - finalAnswerDelivered = false; - if (streamMode !== "progress") { - resetProgressDraftState(); - } - if (answerLane.finalized) { - await rotateLaneForNewMessage(answerLane); - rotateAnswerLaneWhenQueuedBlocksSettle = false; - } else if ( - answerLane.hasStreamedMessage && - !activeAnswerDraftIsToolProgressOnly - ) { - rotateAnswerLaneWhenQueuedBlocksSettle = true; - } - }) - : undefined, - onReasoningEnd: reasoningLane.stream - ? () => - enqueueDraftLaneEvent(async () => { - splitReasoningOnNextStream = reasoningLane.hasStreamedMessage; - resetProgressDraftState(); - }) - : undefined, - suppressDefaultToolProgressMessages: - !streamDeliveryEnabled || Boolean(answerLane.stream), - allowProgressCallbacksWhenSourceDeliverySuppressed: - !isRoomEvent && Boolean(answerLane.stream), - onVerboseProgressVisibility: (isActive) => { - verboseProgressActive = isActive; - }, - commentaryProgressEnabled: - streamMode === "progress" ? progressDraft.commentaryProgressEnabled : undefined, - onToolStart: async (payload) => { - const toolName = payload.name?.trim(); - const progressPromise = pushStreamToolProgress( - formatChannelProgressDraftLineForEntry( - telegramCfg, - { - event: "tool", - name: toolName, - phase: payload.phase, - args: payload.args, - }, - payload.detailMode ? { detailMode: payload.detailMode } : undefined, - ), - { toolName, startImmediately: true }, - ); - if (statusReactionController && toolName) { - await statusReactionController.setTool(toolName); - } - await progressPromise; - }, - onItemEvent: async (payload) => { - if (payload.kind === "preamble") { - if (verboseProgressActive()) { + if (isSilentErrorPolicy(errorPolicy.policy)) { return; } - await progressDraft.pushCommentaryProgress(payload.progressText, { - itemId: payload.itemId, - }); - return; - } - await pushStreamToolProgress( - buildChannelProgressDraftLineForEntry(telegramCfg, { - event: "item", - itemId: payload.itemId, - itemKind: payload.kind, - title: payload.title, - name: payload.name, - phase: payload.phase, - status: payload.status, - summary: payload.summary, - progressText: payload.progressText, - meta: payload.meta, - }), - ); - }, - onPlanUpdate: async (payload) => { - if (payload.phase !== "update") { - return; - } - await pushStreamToolProgress( - formatChannelProgressDraftLine({ - event: "plan", - phase: payload.phase, - title: payload.title, - explanation: payload.explanation, - steps: payload.steps, - }), - ); - }, - onApprovalEvent: async (payload) => { - if (payload.phase !== "requested") { - return; - } - await pushStreamToolProgress( - formatChannelProgressDraftLine({ - event: "approval", - phase: payload.phase, - title: payload.title, - command: payload.command, - reason: payload.reason, - message: payload.message, - }), - ); - }, - onCommandOutput: async (payload) => { - if (payload.phase !== "end") { - return; - } - await pushStreamToolProgress( - formatChannelProgressDraftLine({ - event: "command-output", - phase: payload.phase, - title: payload.title, - name: payload.name, - status: payload.status, - exitCode: payload.exitCode, - }), - ); - }, - onPatchSummary: async (payload) => { - if (payload.phase !== "end") { - return; - } - await pushStreamToolProgress( - formatChannelProgressDraftLine({ - event: "patch", - phase: payload.phase, - title: payload.title, - name: payload.name, - added: payload.added, - modified: payload.modified, - deleted: payload.deleted, - summary: payload.summary, - }), - ); - }, - onCompactionStart: statusReactionController - ? async () => { - await statusReactionController.setCompacting(); + if ( + errorPolicy.policy === "once" && + shouldSuppressTelegramError({ + scopeKey: buildTelegramErrorScopeKey({ + accountId: route.accountId, + chatId, + threadId: threadSpec.id, + }), + cooldownMs: errorPolicy.cooldownMs, + errorMessage: String(err), + }) + ) { + return; } - : undefined, - onCompactionEnd: statusReactionController - ? async () => { - statusReactionController.cancelPending(); - await statusReactionController.setThinking(); + deliveryState.markNonSilentFailure(); + runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); + }, + }, + replyOptions: { + skillFilter, + disableBlockStreaming, + abortSignal: replyAbortController.signal, + sourceReplyDeliveryMode: isRoomEvent ? "message_tool_only" : undefined, + queuedDeliveryCorrelations: isRoomEvent + ? [{ begin: beginDeliveryCorrelation }] + : undefined, + queuedFollowupLifecycle: + isRoomEvent || peerBotTurn + ? { + onEnqueued: () => { + replyAbortControllerQueued = true; + }, + onComplete: () => { + replyAbortControllerQueued = false; + releaseTelegramReplyFenceAbortController( + activeReplyFenceKey, + replyAbortController, + ); + }, + } + : undefined, + queuedDeliveryPayloadTransform: peerBotTurn + ? transformQueuedPeerBotPayload + : undefined, + queuedDeliveryReplyToMode: peerBotTurn ? effectiveReplyToMode : undefined, + queuedDeliveryPayloadDidDeliver: peerBotTurn + ? commitPeerImplicitReply + : undefined, + queuedExecutionContext: peerBotTurn + ? (run) => runWithTelegramPeerBotTurn(peerBotTurn, run) + : undefined, + suppressTyping: isRoomEvent, + onPartialReply: + answerLane.stream || reasoningLane.stream + ? (payload) => + enqueueDraftLaneEvent(async () => { + await ingestDraftLaneSegments(payload); + }) + : undefined, + onBlockReplyQueued: answerLane.stream + ? (payload, blockContext) => + enqueueDraftLaneEvent(async () => { + await prepareQueuedAnswerBlock(payload, blockContext); + }) + : undefined, + onReasoningStream: reasoningLane.stream + ? (payload) => + enqueueDraftLaneEvent(async () => { + if (splitReasoningOnNextStream) { + reasoningLane.stream?.forceNewMessage(); + resetDraftLaneState(reasoningLane); + splitReasoningOnNextStream = false; + } + await ingestDraftLaneSegments(payload, true); + }) + : streamReasoningInProgressDraft + ? (payload) => + enqueueDraftLaneEvent(async () => { + await pushStreamReasoningProgress(payload); + }) + : undefined, + onAssistantMessageStart: answerLane.stream + ? () => + enqueueDraftLaneEvent(async () => { + reasoningStepState.resetForNextStep(); + finalAnswerDelivered = false; + if (streamMode !== "progress") { + resetProgressDraftState(); + } + if (answerLane.finalized) { + await rotateLaneForNewMessage(answerLane); + rotateAnswerLaneWhenQueuedBlocksSettle = false; + } else if ( + answerLane.hasStreamedMessage && + !activeAnswerDraftIsToolProgressOnly + ) { + rotateAnswerLaneWhenQueuedBlocksSettle = true; + } + }) + : undefined, + onReasoningEnd: reasoningLane.stream + ? () => + enqueueDraftLaneEvent(async () => { + splitReasoningOnNextStream = reasoningLane.hasStreamedMessage; + resetProgressDraftState(); + }) + : undefined, + suppressDefaultToolProgressMessages: + !streamDeliveryEnabled || Boolean(answerLane.stream), + allowProgressCallbacksWhenSourceDeliverySuppressed: + !isRoomEvent && Boolean(answerLane.stream), + onVerboseProgressVisibility: (isActive) => { + verboseProgressActive = isActive; + }, + commentaryProgressEnabled: + streamMode === "progress" + ? progressDraft.commentaryProgressEnabled + : undefined, + onToolStart: async (payload) => { + const toolName = payload.name?.trim(); + const progressPromise = pushStreamToolProgress( + formatChannelProgressDraftLineForEntry( + telegramCfg, + { + event: "tool", + name: toolName, + phase: payload.phase, + args: payload.args, + }, + payload.detailMode ? { detailMode: payload.detailMode } : undefined, + ), + { toolName, startImmediately: true }, + ); + if (statusReactionController && toolName) { + await statusReactionController.setTool(toolName); } - : undefined, - onModelSelected, - }, - }); - }, - }), - }, - }); + await progressPromise; + }, + onItemEvent: async (payload) => { + if (payload.kind === "preamble") { + if (verboseProgressActive()) { + return; + } + await progressDraft.pushCommentaryProgress(payload.progressText, { + itemId: payload.itemId, + }); + return; + } + await pushStreamToolProgress( + buildChannelProgressDraftLineForEntry(telegramCfg, { + event: "item", + itemId: payload.itemId, + itemKind: payload.kind, + title: payload.title, + name: payload.name, + phase: payload.phase, + status: payload.status, + summary: payload.summary, + progressText: payload.progressText, + meta: payload.meta, + }), + ); + }, + onPlanUpdate: async (payload) => { + if (payload.phase !== "update") { + return; + } + await pushStreamToolProgress( + formatChannelProgressDraftLine({ + event: "plan", + phase: payload.phase, + title: payload.title, + explanation: payload.explanation, + steps: payload.steps, + }), + ); + }, + onApprovalEvent: async (payload) => { + if (payload.phase !== "requested") { + return; + } + await pushStreamToolProgress( + formatChannelProgressDraftLine({ + event: "approval", + phase: payload.phase, + title: payload.title, + command: payload.command, + reason: payload.reason, + message: payload.message, + }), + ); + }, + onCommandOutput: async (payload) => { + if (payload.phase !== "end") { + return; + } + await pushStreamToolProgress( + formatChannelProgressDraftLine({ + event: "command-output", + phase: payload.phase, + title: payload.title, + name: payload.name, + status: payload.status, + exitCode: payload.exitCode, + }), + ); + }, + onPatchSummary: async (payload) => { + if (payload.phase !== "end") { + return; + } + await pushStreamToolProgress( + formatChannelProgressDraftLine({ + event: "patch", + phase: payload.phase, + title: payload.title, + name: payload.name, + added: payload.added, + modified: payload.modified, + deleted: payload.deleted, + summary: payload.summary, + }), + ); + }, + onCompactionStart: statusReactionController + ? async () => { + await statusReactionController.setCompacting(); + } + : undefined, + onCompactionEnd: statusReactionController + ? async () => { + statusReactionController.cancelPending(); + await statusReactionController.setThinking(); + } + : undefined, + onModelSelected, + }, + }); + }, + }), + }, + }); + const turnResult = peerBotTurn + ? await runWithTelegramPeerBotTurn(peerBotTurn, runInboundTurn) + : await runInboundTurn(); if (!turnResult.dispatched) { - return { kind: "completed" }; + suppressSilentReplyFallback = true; + } else { + ({ queuedFinal } = turnResult.dispatchResult); + suppressSilentReplyFallback = + turnResult.dispatchResult.sourceReplyDeliveryMode === "message_tool_only"; } - ({ queuedFinal } = turnResult.dispatchResult); - suppressSilentReplyFallback = - turnResult.dispatchResult.sourceReplyDeliveryMode === "message_tool_only"; } catch (err) { dispatchError = err; runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`)); @@ -2472,19 +2603,24 @@ export const dispatchTelegramMessage = async ({ const shouldSendFailureFallback = !isRoomEvent && !suppressFailureFallback && - (dispatchError || - (!deliverySummary.delivered && - (deliverySummary.skippedNonSilent > 0 || deliverySummary.failedNonSilent > 0))); + (dispatchError + ? !terminalReplyVisible + : !deliverySummary.delivered && + (deliverySummary.skippedNonSilent > 0 || deliverySummary.failedNonSilent > 0)); if (shouldSendFailureFallback) { const fallbackText = dispatchError ? "Something went wrong while processing your request. Please try again." : EMPTY_RESPONSE_FALLBACK; + const fallbackPayload = applyPeerImplicitReply({ text: fallbackText }); const result = await (telegramDeps.deliverReplies ?? deliverReplies)({ - replies: [{ text: fallbackText }], + replies: [fallbackPayload], ...deliveryBaseOptions, silent: silentErrorReplies && (dispatchError != null || hadErrorReplyFailureOrSkip), mediaLoader: telegramDeps.loadWebMedia, }); + if (result.delivered) { + commitPeerImplicitReply(fallbackPayload); + } sentFallback = result.delivered; } diff --git a/extensions/telegram/src/bot-message.ts b/extensions/telegram/src/bot-message.ts index f9286eef355a..dc9fb44df283 100644 --- a/extensions/telegram/src/bot-message.ts +++ b/extensions/telegram/src/bot-message.ts @@ -50,7 +50,7 @@ type TelegramMessageProcessorDeps = Omit< streamMode: TelegramStreamMode; textLimit: number; telegramDeps: TelegramBotDeps; - opts: Pick; + opts: Pick; }; export type TelegramMessageProcessorLifecycle = { diff --git a/extensions/telegram/src/bot-native-commands.fixture-test-support.ts b/extensions/telegram/src/bot-native-commands.fixture-test-support.ts index 84665835477b..19b24830f7ae 100644 --- a/extensions/telegram/src/bot-native-commands.fixture-test-support.ts +++ b/extensions/telegram/src/bot-native-commands.fixture-test-support.ts @@ -59,6 +59,7 @@ export function createNativeCommandTestParams( shouldSkipUpdate: params.shouldSkipUpdate ?? (() => false), telegramDeps: params.telegramDeps, opts: params.opts ?? { token: "token" }, + peerBotAdmission: params.peerBotAdmission, }; } diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index 5f4de16bedc1..0b179511ead6 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js"; import { createCommandBot, createNativeCommandTestParams, @@ -15,6 +16,7 @@ import { } from "./bot-native-commands.menu-test-support.js"; import { resetTelegramForumFlagCacheForTest } from "./bot/helpers.js"; import { TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js"; +import type { TelegramPeerBotAdmissionCoordinator } from "./peer-bot-admission.js"; import { pluginCommandMocks, resetPluginCommandMocks } from "./test-support/plugin-command.js"; let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands; @@ -105,6 +107,25 @@ function firstDeliverRepliesParams() { return firstCallArg(deliverReplies as unknown as { mock: { calls: Array> } }); } +function deliverRepliesParamsAt(index: number) { + const calls = (deliverReplies as unknown as { mock: { calls: Array> } }).mock + .calls; + const params = calls[index]?.[0]; + if (!params) { + throw new Error(`expected deliverReplies call ${index}`); + } + return params as Record; +} + +function requireTelegramDeps( + params: ReturnType, +): TelegramNativeCommandDeps { + if (!params.telegramDeps) { + throw new Error("expected telegram native command dependencies"); + } + return params.telegramDeps; +} + function firstExecutePluginCommandParams() { return firstCallArg( pluginCommandMocks.executePluginCommand as unknown as { @@ -146,6 +167,21 @@ function registerCustomTelegramCommandMenu( return { runtimeLog, setMyCommands }; } +function createPeerBotAdmissionTestCoordinator(): { + coordinator: TelegramPeerBotAdmissionCoordinator; + cancel: ReturnType; +} { + const cancel = vi.fn(async () => undefined); + return { + cancel, + coordinator: { + cancel, + registerCancellation: vi.fn(() => () => undefined), + reserve: vi.fn(() => async () => false), + }, + }; +} + describe("registerTelegramNativeCommands", () => { beforeAll(async () => { ({ @@ -412,6 +448,335 @@ describe("registerTelegramNativeCommands", () => { expect(parseTelegramNativeCommandCallbackData("tgcmd:fast status")).toBeNull(); }); + it("commits the peer reply target after partially visible native delivery", async () => { + const { bot, commandHandlers } = createCommandBot(); + const cfg: OpenClawConfig = { + commands: { native: true }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }; + const baseParams = createNativeCommandTestParams(cfg, { + bot, + allowFrom: ["*"], + groupAllowFrom: ["*"], + replyToMode: "first", + opts: { token: "token", replyToMode: "first" }, + }); + const dispatchReplyWithBufferedBlockDispatcher: TelegramNativeCommandDeps["dispatchReplyWithBufferedBlockDispatcher"] = + async (params) => { + const deliver = params.dispatcherOptions.deliver; + const info = { kind: "block" } as Parameters[1]; + try { + await deliver({ text: "partially visible" }, info); + } catch {} + await deliver({ text: "next payload" }, info); + return { queuedFinal: false, counts: { block: 2, final: 0, tool: 0 } }; + }; + const visibleError = Object.assign(new Error("second chunk failed"), { + sentBeforeError: true, + visibleReplySent: true, + }); + deliverReplies.mockRejectedValueOnce(visibleError).mockResolvedValueOnce({ delivered: true }); + + registerTelegramNativeCommands({ + ...baseParams, + telegramDeps: { + ...requireTelegramDeps(baseParams), + dispatchReplyWithBufferedBlockDispatcher, + }, + }); + const handler = commandHandlers.get("compact"); + if (!handler) { + throw new Error("expected compact command handler to be registered"); + } + await handler({ + message: { + chat: { id: -1234, type: "group", title: "Bot commands" }, + from: { id: 42, is_bot: true, first_name: "Peer", username: "peer_bot" }, + text: "/compact", + date: 1_736_380_800, + message_id: 5, + }, + me: { id: 99, username: "openclaw_bot" }, + match: "", + }); + + expect(deliverReplies).toHaveBeenCalledTimes(2); + expect(replyAt(deliverRepliesParamsAt(0)).replyToId).toBe("5"); + expect(replyAt(deliverRepliesParamsAt(1)).replyToId).toBeUndefined(); + }); + + it("sends a peer native-command fallback after an invisible delivery failure", async () => { + const { bot, commandHandlers } = createCommandBot(); + const cfg: OpenClawConfig = { + commands: { native: true }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }; + const baseParams = createNativeCommandTestParams(cfg, { + bot, + allowFrom: ["*"], + groupAllowFrom: ["*"], + replyToMode: "first", + opts: { token: "token", replyToMode: "first" }, + }); + const dispatchReplyWithBufferedBlockDispatcher: TelegramNativeCommandDeps["dispatchReplyWithBufferedBlockDispatcher"] = + async (params) => { + const info = { kind: "block" } as Parameters[1]; + try { + await params.dispatcherOptions.deliver({ text: "failed framed response" }, info); + } catch (error) { + await params.dispatcherOptions.onError?.(error, info); + } + return { queuedFinal: false, counts: { block: 1, final: 0, tool: 0 } }; + }; + deliverReplies + .mockRejectedValueOnce(new Error("second chunk failed")) + .mockResolvedValueOnce({ delivered: true }); + + registerTelegramNativeCommands({ + ...baseParams, + telegramDeps: { + ...requireTelegramDeps(baseParams), + dispatchReplyWithBufferedBlockDispatcher, + }, + }); + const handler = commandHandlers.get("compact"); + if (!handler) { + throw new Error("expected compact command handler to be registered"); + } + await handler({ + message: { + chat: { id: -1236, type: "group", title: "Bot commands" }, + from: { id: 44, is_bot: true, first_name: "Peer", username: "peer_bot" }, + text: "/compact", + date: 1_736_380_800, + message_id: 7, + }, + me: { id: 99, username: "openclaw_bot" }, + match: "", + }); + + expect(deliverReplies).toHaveBeenCalledTimes(2); + expect(replyAt(deliverRepliesParamsAt(1))).toMatchObject({ + text: "No response generated. Please try again.", + replyToId: "7", + }); + }); + + it("does not let an unauthorized peer stop cancel buffered work", async () => { + const { bot, commandHandlers, sendMessage } = createCommandBot(); + const { coordinator, cancel } = createPeerBotAdmissionTestCoordinator(); + const cfg: OpenClawConfig = { + commands: { native: true, allowFrom: { telegram: ["999"] } }, + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }; + registerTelegramNativeCommands({ + ...createNativeCommandTestParams(cfg, { + bot, + allowFrom: ["*"], + groupAllowFrom: ["*"], + }), + peerBotAdmission: coordinator, + }); + const handler = commandHandlers.get("stop"); + if (!handler) { + throw new Error("expected stop command handler to be registered"); + } + + await handler({ + message: { + chat: { id: -2234, type: "group", title: "Bot commands" }, + from: { id: 42, is_bot: true, first_name: "Peer", username: "peer_bot" }, + text: "/stop", + date: 1_736_380_800, + message_id: 5, + }, + me: { id: 99, username: "openclaw_bot" }, + match: "", + }); + await handler({ + message: { + chat: { id: -2234, type: "group", title: "Bot commands" }, + from: { id: 42, is_bot: true, first_name: "Peer", username: "peer_bot" }, + text: "/stop", + date: 1_736_380_800, + message_id: 6, + }, + me: { id: 99, username: "openclaw_bot" }, + match: "", + }); + + expect(cancel).not.toHaveBeenCalled(); + expect( + sendMessage.mock.calls.filter( + (call) => call[1] === "You are not authorized to use this command.", + ), + ).toHaveLength(1); + }); + + it("loop-suppresses unmatched peer plugin-command replies", async () => { + const { handler, sendMessage } = registerPlugCommand({ + cfg: { + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }, + }); + pluginCommandMocks.matchPluginCommand.mockReturnValue(null as never); + const context = { + message: { + chat: { id: -1235, type: "group", title: "Bot plugins" }, + from: { id: 43, is_bot: true, first_name: "Peer", username: "peer_bot" }, + text: "/plug", + date: 1_736_380_800, + message_id: 1, + }, + me: { id: 99, username: "openclaw_bot" }, + match: "", + }; + + await handler(context); + await handler({ + ...context, + message: { ...context.message, message_id: 2 }, + }); + + expect(sendMessage.mock.calls.filter((call) => call[1] === "Command not found.")).toHaveLength( + 1, + ); + }); + + it("uses the canonical General topic and cancels even when peer stop is loop-suppressed", async () => { + const getChat = vi.fn(async () => ({ id: -3234, type: "supergroup", is_forum: true })); + const { bot, commandHandlers } = createCommandBot({ api: { getChat } }); + const { coordinator, cancel } = createPeerBotAdmissionTestCoordinator(); + const cfg: OpenClawConfig = { + commands: { native: true, allowFrom: { telegram: ["42"] } }, + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }; + registerTelegramNativeCommands({ + ...createNativeCommandTestParams(cfg, { bot }), + peerBotAdmission: coordinator, + }); + const handler = commandHandlers.get("stop"); + if (!handler) { + throw new Error("expected stop command handler to be registered"); + } + + await handler({ + message: { + chat: { id: -3234, type: "supergroup", title: "Forum commands" }, + from: { id: 42, is_bot: true, first_name: "Peer", username: "peer_bot" }, + text: "/stop", + date: 1_736_380_800, + message_id: 5, + }, + me: { id: 99, username: "openclaw_bot" }, + match: "", + }); + await handler({ + message: { + chat: { id: -3234, type: "supergroup", title: "Forum commands" }, + from: { id: 42, is_bot: true, first_name: "Peer", username: "peer_bot" }, + text: "/stop", + date: 1_736_380_800, + message_id: 6, + }, + me: { id: 99, username: "openclaw_bot" }, + match: "", + }); + + expect(cancel.mock.calls).toEqual([["default:-3234:1:42:99"], ["default:-3234:1:42:99"]]); + }); + + it("omits non-forum reply threads from peer stop cancellation", async () => { + const getChat = vi.fn(async () => ({ id: -4234, type: "supergroup", is_forum: false })); + const { bot, commandHandlers } = createCommandBot({ api: { getChat } }); + const { coordinator, cancel } = createPeerBotAdmissionTestCoordinator(); + const cfg: OpenClawConfig = { + commands: { native: true, allowFrom: { telegram: ["42"] } }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }; + registerTelegramNativeCommands({ + ...createNativeCommandTestParams(cfg, { bot }), + peerBotAdmission: coordinator, + }); + const handler = commandHandlers.get("stop"); + if (!handler) { + throw new Error("expected stop command handler to be registered"); + } + + await handler({ + message: { + chat: { id: -4234, type: "supergroup", title: "Group commands" }, + message_thread_id: 77, + from: { id: 42, is_bot: true, first_name: "Peer", username: "peer_bot" }, + text: "/stop", + date: 1_736_380_800, + message_id: 5, + }, + me: { id: 99, username: "openclaw_bot" }, + match: "", + }); + + expect(cancel).toHaveBeenCalledWith("default:-4234:main:42:99"); + }); + it("passes agent-scoped media roots for plugin command replies with media", async () => { const mediaMaxBytes = 50 * 1024 * 1024; const cfg: OpenClawConfig = { diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 0e811c5877e5..f8650408ce08 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -8,6 +8,7 @@ import { resolveDefaultModelForAgent, resolveThinkingDefaultWithRuntimeCatalog, } from "openclaw/plugin-sdk/agent-runtime"; +import { recordChannelBotPairLoopAndCheckSuppression } from "openclaw/plugin-sdk/channel-inbound"; import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound"; import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native"; import { @@ -32,6 +33,7 @@ import type { } from "openclaw/plugin-sdk/config-contracts"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; +import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; @@ -93,6 +95,7 @@ import { resolveTelegramConversationBaseSessionKey, resolveTelegramConversationRoute, } from "./conversation-route.js"; +import { isTelegramDeliveryErrorVisible } from "./delivery-error.js"; import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; import type { TelegramTransport } from "./fetch.js"; import { @@ -103,6 +106,12 @@ import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js"; import { resolveTelegramCommandIngressAuthorization } from "./ingress.js"; import { buildInlineKeyboard } from "./inline-keyboard.js"; import { buildTelegramNativeCommandCallbackData } from "./native-command-callback-data.js"; +import { + buildTelegramPeerBotAdmissionKey, + createTelegramPeerBotAdmissionCoordinator, + type TelegramPeerBotAdmissionCoordinator, +} from "./peer-bot-admission.js"; +import { runWithTelegramPeerBotTurn } from "./peer-bot-turn.js"; import { recordSentMessage } from "./sent-message-cache.js"; import { getTopicName, resolveTopicNameCacheScope } from "./topic-name-cache.js"; export { @@ -121,6 +130,37 @@ type TelegramNativeReplyChannelData = { buttons?: TelegramInlineButtons; pin?: boolean; }; + +function isTelegramPeerBotMessage(msg: TelegramNativeCommandContext["message"]): boolean { + return msg?.from?.is_bot === true && msg.sender_chat == null; +} + +function shouldSuppressTelegramBotCommandLoop(params: { + msg: TelegramNativeCommandContext["message"]; + botId?: number; + accountId: string; + cfg: OpenClawConfig; +}): boolean { + const msg = params.msg; + const sender = msg?.from; + if ( + !msg || + !isTelegramPeerBotMessage(msg) || + !sender || + params.botId == null || + sender.id === params.botId + ) { + return false; + } + return recordChannelBotPairLoopAndCheckSuppression({ + scopeId: params.accountId, + conversationId: `${msg.chat.id}:${msg.message_thread_id ?? ""}`, + senderId: String(sender.id), + receiverId: String(params.botId), + defaultsConfig: params.cfg.channels?.defaults?.botLoopProtection, + defaultEnabled: true, + }).suppressed; +} type TelegramResolvedGroupConfig = { groupConfig?: TelegramGroupConfig | TelegramDirectConfig; topicConfig?: TelegramTopicConfig; @@ -131,6 +171,7 @@ type TelegramCommandAuthResult = { isGroup: boolean; isForum: boolean; resolvedThreadId?: number; + admissionThreadId?: number; senderId: string; senderUsername: string; groupConfig?: TelegramGroupConfig | TelegramDirectConfig; @@ -447,6 +488,7 @@ export type RegisterTelegramHandlerParams = { lifecycle?: import("./bot-message.js").TelegramMessageProcessorLifecycle, ) => Promise; logger: ReturnType; + peerBotAdmission?: TelegramPeerBotAdmissionCoordinator; }; export function resolveTelegramNativeCommandDisableBlockStreaming( @@ -478,7 +520,8 @@ export type RegisterTelegramNativeCommandsParams = { ) => TelegramResolvedGroupConfig; shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean; telegramDeps?: TelegramNativeCommandDeps; - opts: { token: string }; + opts: Pick; + peerBotAdmission?: TelegramPeerBotAdmissionCoordinator; }; async function resolveTelegramCommandAuth(params: { @@ -487,6 +530,7 @@ async function resolveTelegramCommandAuth(params: { cfg: OpenClawConfig; accountId: string; telegramCfg: TelegramAccountConfig; + replyToMode: ReplyToMode; readChannelAllowFromStore: TelegramBotDeps["readChannelAllowFromStore"]; allowFrom?: Array; groupAllowFrom?: Array; @@ -497,6 +541,7 @@ async function resolveTelegramCommandAuth(params: { messageThreadId?: number, ) => TelegramResolvedGroupConfig; requireAuth: boolean; + shouldSuppressRejection?: () => boolean; }): Promise { const { msg, @@ -504,6 +549,7 @@ async function resolveTelegramCommandAuth(params: { cfg, accountId, telegramCfg, + replyToMode, readChannelAllowFromStore, allowFrom, groupAllowFrom, @@ -511,6 +557,7 @@ async function resolveTelegramCommandAuth(params: { resolveGroupPolicy, resolveTelegramGroupConfig, requireAuth, + shouldSuppressRejection, } = params; const { chatId, isGroup, isForum, messageThreadId, threadParams } = await resolveTelegramNativeCommandThreadContext({ msg, bot }); @@ -557,6 +604,7 @@ async function resolveTelegramCommandAuth(params: { effectiveGroupAllow, hasGroupAllowOverride, } = groupAllowContext; + const admissionThreadId = resolvedThreadId ?? dmThreadId; const effectiveDmPolicy = resolveTelegramEffectiveDmPolicy({ isGroup, groupConfig, @@ -591,9 +639,23 @@ async function resolveTelegramCommandAuth(params: { }); const sendAuthMessage = async (text: string) => { + if (shouldSuppressRejection?.()) { + return null; + } await withTelegramApiErrorLogging({ operation: "sendMessage", - fn: () => bot.api.sendMessage(chatId, text, threadParams ?? {}), + fn: () => + bot.api.sendMessage(chatId, text, { + ...(isTelegramPeerBotMessage(msg) && replyToMode !== "off" + ? { + reply_parameters: { + message_id: msg.message_id, + allow_sending_without_reply: true, + }, + } + : {}), + ...threadParams, + }), }); return null; }; @@ -692,6 +754,7 @@ async function resolveTelegramCommandAuth(params: { isGroup, isForum, resolvedThreadId, + ...(admissionThreadId != null ? { admissionThreadId } : {}), senderId, senderUsername, groupConfig, @@ -721,7 +784,65 @@ export const registerTelegramNativeCommands = ({ shouldSkipUpdate, telegramDeps = defaultTelegramNativeCommandDeps, opts, + peerBotAdmission = createTelegramPeerBotAdmissionCoordinator(), }: RegisterTelegramNativeCommandsParams) => { + // Peer-bot replies default to explicit threading for Telegram visibility. + // Operators can still disable the exception with replyToMode: "off". + const peerBotReplyToMode = opts.replyToMode ?? telegramCfg.replyToMode ?? "all"; + const shouldSuppressPeerBotCommandLoop = (params: { + msg: NonNullable; + botId?: number; + runtimeCfg: OpenClawConfig; + }): boolean => + shouldSuppressTelegramBotCommandLoop({ + msg: params.msg, + botId: params.botId, + accountId, + cfg: params.runtimeCfg, + }); + const admitAuthorizedPeerBotCommand = async (params: { + msg: NonNullable; + botId?: number; + isAbortControl: boolean; + threadId?: number; + runtimeCfg: OpenClawConfig; + }): Promise => { + if (!isTelegramPeerBotMessage(params.msg) || params.botId == null || !params.msg.from) { + return false; + } + const admissionKey = buildTelegramPeerBotAdmissionKey({ + accountId, + chatId: params.msg.chat.id, + threadId: params.threadId, + senderId: String(params.msg.from.id), + receiverId: params.botId, + }); + if (params.isAbortControl) { + // Authorized stop always cancels buffered peer work, even when loop + // protection suppresses its command response. + await peerBotAdmission.cancel(admissionKey); + if ( + shouldSuppressPeerBotCommandLoop({ + msg: params.msg, + botId: params.botId, + runtimeCfg: params.runtimeCfg, + }) + ) { + return true; + } + return false; + } + return await peerBotAdmission.reserve( + admissionKey, + (admitted) => + admitted && + shouldSuppressPeerBotCommandLoop({ + msg: params.msg, + botId: params.botId, + runtimeCfg: params.runtimeCfg, + }), + )(true); + }; const boundRoute = nativeEnabled && nativeSkillsEnabled ? resolveAgentRoute({ cfg, channel: "telegram", accountId }) @@ -937,7 +1058,17 @@ export const registerTelegramNativeCommands = ({ bot.api.sendMessage( chatId, "Configured ACP binding is unavailable right now. Please try again.", - buildTelegramThreadParams(threadSpec) ?? {}, + { + ...buildTelegramThreadParams(threadSpec), + ...(isTelegramPeerBotMessage(msg) && peerBotReplyToMode !== "off" + ? { + reply_parameters: { + message_id: msg.message_id, + allow_sending_without_reply: true, + }, + } + : {}), + }, ), }); return null; @@ -972,6 +1103,8 @@ export const registerTelegramNativeCommands = ({ tableMode: ReturnType; chunkMode: TelegramChunkMode; linkPreview?: boolean; + standardMessages?: boolean; + defaultReplyToId?: string; }) => ({ cfg: params.cfg, chatId: String(params.chatId), @@ -985,12 +1118,14 @@ export const registerTelegramNativeCommands = ({ bot, mediaLocalRoots: params.mediaLocalRoots, mediaMaxBytes, - replyToMode, + replyToMode: params.standardMessages ? peerBotReplyToMode : replyToMode, textLimit, thread: params.threadSpec, tableMode: params.tableMode, chunkMode: params.chunkMode, linkPreview: params.linkPreview, + standardMessages: params.standardMessages, + defaultReplyToId: params.defaultReplyToId, }); const resolveCommandTargetSessionKey = (params: { runtimeCfg: OpenClawConfig; @@ -1031,17 +1166,22 @@ export const registerTelegramNativeCommands = ({ if (!msg) { return; } + if (msg.from?.id != null && msg.from.id === ctx.me?.id) { + return; + } if (shouldSkipUpdate(ctx)) { return; } const runtimeCfg = loadFreshRuntimeConfig(); const runtimeTelegramCfg = resolveFreshTelegramConfig(runtimeCfg); + const botId = ctx.me?.id ?? bot.botInfo?.id; const auth = await resolveTelegramCommandAuth({ msg, bot, cfg: runtimeCfg, accountId, telegramCfg: runtimeTelegramCfg, + replyToMode: peerBotReplyToMode, readChannelAllowFromStore: telegramDeps.readChannelAllowFromStore, allowFrom, groupAllowFrom, @@ -1049,10 +1189,23 @@ export const registerTelegramNativeCommands = ({ resolveGroupPolicy, resolveTelegramGroupConfig, requireAuth: true, + shouldSuppressRejection: () => + shouldSuppressPeerBotCommandLoop({ msg, botId, runtimeCfg }), }); if (!auth) { return; } + if ( + await admitAuthorizedPeerBotCommand({ + msg, + botId, + isAbortControl: normalizedCommandName === "stop", + threadId: auth.admissionThreadId, + runtimeCfg, + }) + ) { + return; + } const { chatId, isGroup, @@ -1177,6 +1330,14 @@ export const registerTelegramNativeCommands = ({ fn: () => bot.api.sendMessage(chatId, title, { ...(replyMarkup ? { reply_markup: replyMarkup } : {}), + ...(isTelegramPeerBotMessage(msg) && peerBotReplyToMode !== "off" + ? { + reply_parameters: { + message_id: msg.message_id, + allow_sending_without_reply: true, + }, + } + : {}), ...threadParams, }), }); @@ -1195,6 +1356,7 @@ export const registerTelegramNativeCommands = ({ userId: String(senderId || chatId), targetSessionKey: sessionKey, }); + const peerBotCommand = isTelegramPeerBotMessage(msg); const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ cfg: executionCfg, chatId, @@ -1208,6 +1370,8 @@ export const registerTelegramNativeCommands = ({ tableMode, chunkMode, linkPreview: runtimeTelegramCfg.linkPreview, + standardMessages: peerBotCommand, + defaultReplyToId: undefined, }); let topicName: string | undefined; if (isForum && resolvedThreadId != null) { @@ -1273,10 +1437,12 @@ export const registerTelegramNativeCommands = ({ runtime.error?.(danger(`telegram slash: failed updating session meta: ${String(err)}`)), }); - const disableBlockStreaming = - resolveTelegramNativeCommandDisableBlockStreaming(runtimeTelegramCfg); + const disableBlockStreaming = isTelegramPeerBotMessage(msg) + ? true + : resolveTelegramNativeCommandDisableBlockStreaming(runtimeTelegramCfg); const deliveryState = { delivered: false, + failedNonSilent: 0, skippedNonSilent: 0, }; @@ -1288,60 +1454,142 @@ export const registerTelegramNativeCommands = ({ channel: "telegram", accountId: route.accountId, }); + const peerBotTurn = + peerBotCommand && msg.from?.id != null + ? { + accountId: route.accountId, + chatAliases: [msg.chat.username] + .filter((value): value is string => Boolean(value)) + .map((value) => `@${value}`), + chatId: String(chatId), + messageId: msg.message_id, + senderAliases: [msg.from?.username] + .filter((value): value is string => Boolean(value)) + .map((value) => `@${value}`), + senderId: String(msg.from.id), + ...(threadSpec.id != null ? { threadId: threadSpec.id } : {}), + } + : undefined; + const effectiveNativeReplyToMode = peerBotCommand ? peerBotReplyToMode : replyToMode; + let peerImplicitReplyAvailable = true; + const applyPeerImplicitReply = (payload: TelegramNativeReplyPayload) => { + if ( + effectiveNativeReplyToMode === "off" || + payload.replyToId != null || + (isSingleUseReplyToMode(effectiveNativeReplyToMode) && !peerImplicitReplyAvailable) + ) { + return payload; + } + return { + ...payload, + replyToId: String(msg.message_id), + replyToIdSource: "implicit" as const, + }; + }; + const commitPeerImplicitReply = (payload: TelegramNativeReplyPayload) => { + if ( + payload.replyToIdSource === "implicit" && + isSingleUseReplyToMode(effectiveNativeReplyToMode) + ) { + peerImplicitReplyAvailable = false; + } + }; + const transformQueuedPeerBotPayload = (payload: TelegramNativeReplyPayload) => { + const addressedPayload = applyPeerImplicitReply(payload); + return { + ...addressedPayload, + channelData: { + ...addressedPayload.channelData, + telegram: { + ...(addressedPayload.channelData?.telegram as Record | undefined), + standardMessage: true, + }, + }, + }; + }; - await telegramDeps.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg: executionCfg, - dispatcherOptions: { - ...replyPipeline, - beforeDeliver: async (payload) => payload, - deliver: async (payload, _info) => { - if ( - shouldSuppressLocalTelegramExecApprovalPrompt({ - cfg: executionCfg, - accountId: route.accountId, - payload, - }) - ) { - deliveryState.delivered = true; - return; - } - const result = await deliverReplies({ - replies: [ - payload.replyToId - ? payload - : { - ...payload, - replyToId: String(msg.message_id), - }, - ], - ...deliveryBaseOptions, - silent: runtimeTelegramCfg.silentErrorReplies === true && payload.isError === true, - }); - if (result.delivered) { - deliveryState.delivered = true; - } + const dispatchNativeCommand = async () => + await telegramDeps.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: executionCfg, + dispatcherOptions: { + ...replyPipeline, + beforeDeliver: async (payload) => payload, + deliver: async (payload, _info) => { + if ( + shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg: executionCfg, + accountId: route.accountId, + payload, + }) + ) { + deliveryState.delivered = true; + return; + } + const addressedPayload = applyPeerImplicitReply(payload); + let result: Awaited>; + try { + result = await deliverReplies({ + replies: [addressedPayload], + ...deliveryBaseOptions, + silent: + runtimeTelegramCfg.silentErrorReplies === true && payload.isError === true, + }); + } catch (error) { + if (isTelegramDeliveryErrorVisible(error)) { + commitPeerImplicitReply(addressedPayload); + deliveryState.delivered = true; + } + const silentFailure = + runtimeTelegramCfg.silentErrorReplies === true && payload.isError === true; + if (!silentFailure) { + deliveryState.failedNonSilent += 1; + } + throw error; + } + if (result.delivered) { + commitPeerImplicitReply(addressedPayload); + deliveryState.delivered = true; + } + }, + onSkip: (_payload, info) => { + if (info.reason !== "silent") { + deliveryState.skippedNonSilent += 1; + } + }, + onError: (err, info) => { + runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`)); + }, }, - onSkip: (_payload, info) => { - if (info.reason !== "silent") { - deliveryState.skippedNonSilent += 1; - } + replyOptions: { + skillFilter, + disableBlockStreaming, + queuedDeliveryPayloadTransform: peerBotCommand + ? transformQueuedPeerBotPayload + : undefined, + queuedDeliveryReplyToMode: peerBotCommand ? effectiveNativeReplyToMode : undefined, + queuedDeliveryPayloadDidDeliver: peerBotCommand ? commitPeerImplicitReply : undefined, + queuedExecutionContext: peerBotTurn + ? (run) => runWithTelegramPeerBotTurn(peerBotTurn, run) + : undefined, + onModelSelected, }, - onError: (err, info) => { - runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`)); - }, - }, - replyOptions: { - skillFilter, - disableBlockStreaming, - onModelSelected, - }, - }); - if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) { - await deliverReplies({ - replies: [{ text: EMPTY_RESPONSE_FALLBACK }], + }); + await (peerBotTurn + ? runWithTelegramPeerBotTurn(peerBotTurn, dispatchNativeCommand) + : dispatchNativeCommand()); + if ( + !deliveryState.delivered && + deliveryState.skippedNonSilent + deliveryState.failedNonSilent > 0 + ) { + const fallbackPayload = applyPeerImplicitReply({ text: EMPTY_RESPONSE_FALLBACK }); + const fallbackResult = await deliverReplies({ + replies: [fallbackPayload], ...deliveryBaseOptions, }); + if (fallbackResult.delivered) { + commitPeerImplicitReply(fallbackPayload); + } } }); } @@ -1352,22 +1600,40 @@ export const registerTelegramNativeCommands = ({ if (!msg) { return; } + if (msg.from?.id != null && msg.from.id === ctx.me?.id) { + return; + } if (shouldSkipUpdate(ctx)) { return; } const chatId = msg.chat.id; const runtimeCfg = loadFreshRuntimeConfig(); const runtimeTelegramCfg = resolveFreshTelegramConfig(runtimeCfg); + const botId = ctx.me?.id ?? bot.botInfo?.id; const { threadParams } = await resolveTelegramNativeCommandThreadContext({ msg, bot }); const rawText = ctx.match?.trim() ?? ""; const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`; const nativeCommandRuntime = await loadTelegramNativeCommandRuntime(); const match = nativeCommandRuntime.matchPluginCommand(commandBody); if (!match) { + if (shouldSuppressPeerBotCommandLoop({ msg, botId, runtimeCfg })) { + return; + } await withTelegramApiErrorLogging({ operation: "sendMessage", runtime, - fn: () => bot.api.sendMessage(chatId, "Command not found.", threadParams ?? {}), + fn: () => + bot.api.sendMessage(chatId, "Command not found.", { + ...(isTelegramPeerBotMessage(msg) && peerBotReplyToMode !== "off" + ? { + reply_parameters: { + message_id: msg.message_id, + allow_sending_without_reply: true, + }, + } + : {}), + ...threadParams, + }), }); return; } @@ -1377,6 +1643,7 @@ export const registerTelegramNativeCommands = ({ cfg: runtimeCfg, accountId, telegramCfg: runtimeTelegramCfg, + replyToMode: peerBotReplyToMode, readChannelAllowFromStore: telegramDeps.readChannelAllowFromStore, allowFrom, groupAllowFrom, @@ -1384,10 +1651,23 @@ export const registerTelegramNativeCommands = ({ resolveGroupPolicy, resolveTelegramGroupConfig, requireAuth: match.command.requireAuth !== false, + shouldSuppressRejection: () => + shouldSuppressPeerBotCommandLoop({ msg, botId, runtimeCfg }), }); if (!auth) { return; } + if ( + await admitAuthorizedPeerBotCommand({ + msg, + botId, + isAbortControl: false, + threadId: auth.admissionThreadId, + runtimeCfg, + }) + ) { + return; + } const { senderId, commandAuthorized, senderIsOwner, isGroup, isForum, resolvedThreadId } = auth; const runtimeContext = await resolveCommandRuntimeContext({ @@ -1430,6 +1710,11 @@ export const registerTelegramNativeCommands = ({ tableMode, chunkMode, linkPreview: runtimeTelegramCfg.linkPreview, + standardMessages: isTelegramPeerBotMessage(msg), + defaultReplyToId: + isTelegramPeerBotMessage(msg) && peerBotReplyToMode !== "off" + ? String(msg.message_id) + : undefined, }); const from = isGroup ? buildTelegramGroupFrom(chatId, threadSpec.id) : `telegram:${chatId}`; const to = `telegram:${chatId}`; @@ -1438,7 +1723,9 @@ export const registerTelegramNativeCommands = ({ let progressMessageId: number | undefined; const progressPlaceholder = resolveTelegramProgressPlaceholder(match.command); - if (progressPlaceholder) { + // Peer bots do not receive rich edits, so bot-originated commands must + // wait for the observable standard final instead of a progress placeholder. + if (progressPlaceholder && deliveryBaseOptions.standardMessages !== true) { try { const sent = await withTelegramApiErrorLogging({ operation: "sendMessage", @@ -1505,9 +1792,19 @@ export const registerTelegramNativeCommands = ({ return; } - const deliverableResult = hasRenderableTelegramNativeReplyPayload(result) + const baseDeliverableResult = hasRenderableTelegramNativeReplyPayload(result) ? result : { text: EMPTY_RESPONSE_FALLBACK }; + const deliverableResult = + isTelegramPeerBotMessage(msg) && + peerBotReplyToMode !== "off" && + baseDeliverableResult.replyToId == null + ? { + ...baseDeliverableResult, + replyToId: String(msg.message_id), + replyToIdSource: "implicit" as const, + } + : baseDeliverableResult; const progressResultText = typeof deliverableResult.text === "string" && deliverableResult.text.trim().length > 0 ? deliverableResult.text diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index 2cda13c8e03c..53530ae35fe2 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -33,6 +33,7 @@ type ReplyPayloadLike = { mediaUrl?: string; mediaUrls?: string[]; replyToId?: string; + replyToIdSource?: "explicit" | "implicit"; }; const { sessionStorePath } = vi.hoisted(() => { diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 418cca6edcc4..f03e4443d5f3 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -11,6 +11,11 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import type { TelegramBotOptions } from "./bot.types.js"; import type { TelegramGetChat } from "./bot/types.js"; import { buildTelegramOpaqueCallbackData } from "./native-command-callback-data.js"; +import { + buildTelegramStandardFragmentAbort, + frameTelegramStandardTextFragments, + TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS, +} from "./standard-text.js"; const harness = await import("./bot.create-telegram-bot.test-harness.js"); const pluginStateTestRuntime = await import("openclaw/plugin-sdk/plugin-state-test-runtime"); const conversationRuntime = await import("openclaw/plugin-sdk/conversation-runtime"); @@ -19,6 +24,18 @@ const sessionStoreRuntime = await import("openclaw/plugin-sdk/session-store-runt const EYES_EMOJI = "\u{1F440}"; const tempStateDirs: string[] = []; let previousStateDir: string | undefined; + +function frameStandardTextFragments( + first: string, + second: string, + ...rest: string[] +): [string, string, ...string[]] { + return frameTelegramStandardTextFragments([first, second, ...rest]) as [ + string, + string, + ...string[], + ]; +} const { answerCallbackQuerySpy, botCtorSpy, @@ -101,6 +118,7 @@ const upsertChannelPairingRequest = getUpsertChannelPairingRequestMock(); const ORIGINAL_TZ = process.env.TZ; const TELEGRAM_TEST_TIMINGS = { mediaGroupFlushMs: 20, + peerBotTextFragmentGapMs: 30, textFragmentGapMs: 30, } as const; @@ -267,6 +285,1611 @@ describe("createTelegramBot", () => { expect(useSpy).toHaveBeenCalledWith(expect.any(Function)); }); + it("suppresses peer-bot loops before message processing", async () => { + loadConfig.mockReturnValue({ + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const buildCtx = (messageId: number) => ({ + update: { update_id: 9_900_000 + messageId }, + message: { + chat: { id: -9_876_543_210, type: "group", title: "Bot loop" }, + from: { id: 8_765_432_100, is_bot: true, username: "peer_bot" }, + text: `ping-${messageId}`, + date: 1_736_380_800, + message_id: messageId, + }, + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + await handler(buildCtx(1)); + await handler(buildCtx(2)); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(JSON.stringify(replySpy.mock.calls)).not.toContain("ping-2"); + }); + + it("does not spend peer-bot loop budget on unmentioned group messages", async () => { + loadConfig.mockReturnValue({ + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const buildCtx = (messageId: number, text: string) => ({ + update: { update_id: 9_910_000 + messageId }, + message: { + chat: { id: -9_876_543_211, type: "group", title: "Bot mention admission" }, + from: { id: 8_765_432_101, is_bot: true, username: "peer_bot" }, + text, + date: 1_736_380_800, + message_id: messageId, + }, + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + await handler(buildCtx(1, "ambient bot chatter")); + await handler(buildCtx(2, "@openclaw_bot please respond")); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(JSON.stringify(replySpy.mock.calls[0]?.[0])).toContain("please respond"); + }); + + it("does not spend peer-bot loop budget before fresh route admission", async () => { + let routeBound = false; + const configForRoute = () => ({ + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + defaultAccount: "work", + groupPolicy: "open" as const, + groups: { "*": { requireMention: false } }, + accounts: { + work: { botToken: "tok-work" }, + opie: { botToken: "tok-opie", groupPolicy: "open" as const }, + }, + }, + }, + agents: { list: [{ id: "agent-a" }] }, + bindings: routeBound + ? [{ agentId: "agent-a", match: { channel: "telegram", accountId: "opie" } }] + : [], + }); + loadConfig.mockImplementation(configForRoute); + createTelegramBot({ token: "tok", accountId: "opie" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const buildCtx = (messageId: number) => ({ + update: { update_id: 9_915_000 + messageId }, + message: { + chat: { id: -9_876_543_214, type: "group", title: "Bot route admission" }, + from: { id: 8_765_432_104, is_bot: true, username: "peer_bot" }, + text: `route-${messageId}`, + date: 1_736_380_800, + message_id: messageId, + }, + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + await handler(buildCtx(1)); + expect(replySpy).not.toHaveBeenCalled(); + + routeBound = true; + await handler(buildCtx(2)); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(JSON.stringify(replySpy.mock.calls[0]?.[0])).toContain("route-2"); + }); + + it("does not spend peer-bot loop budget before voice mention admission", async () => { + loadConfig.mockReturnValue({ + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const baseCtx = (messageId: number) => ({ + update: { update_id: 9_920_000 + messageId }, + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + await handler({ + ...baseCtx(1), + message: { + chat: { id: -9_876_543_212, type: "group", title: "Bot voice admission" }, + from: { id: 8_765_432_102, is_bot: true, username: "peer_bot" }, + voice: { duration: 1, file_id: "voice-file", file_unique_id: "voice-unique" }, + date: 1_736_380_800, + message_id: 1, + }, + }); + await handler({ + ...baseCtx(2), + message: { + chat: { id: -9_876_543_212, type: "group", title: "Bot voice admission" }, + from: { id: 8_765_432_102, is_bot: true, username: "peer_bot" }, + text: "@openclaw_bot please respond after voice", + date: 1_736_380_800, + message_id: 2, + }, + }); + + expect(JSON.stringify(replySpy.mock.calls)).toContain("please respond after voice"); + }); + + it("suppresses an explicitly addressed peer-bot voice before downloading it", async () => { + loadConfig.mockReturnValue({ + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const getFile = vi.fn(async () => ({ download: async () => new Uint8Array() })); + const chat = { id: -9_876_543_232, type: "group", title: "Bot voice cooldown" }; + const peer = { id: 8_765_432_122, is_bot: true, username: "peer_bot" }; + const me = { id: 7_654_321_000, is_bot: true, username: "openclaw_bot" }; + + await handler({ + update: { update_id: 9_930_001 }, + me, + getFile, + message: { + chat, + from: peer, + text: "@openclaw_bot prime loop budget", + date: 1_736_380_800, + message_id: 1, + }, + }); + await handler({ + update: { update_id: 9_930_002 }, + me, + getFile, + message: { + chat, + from: peer, + voice: { duration: 1, file_id: "voice-file", file_unique_id: "voice-unique" }, + reply_to_message: { + chat, + from: me, + text: "prime reply", + date: 1_736_380_800, + message_id: 1_000, + }, + date: 1_736_380_800, + message_id: 2, + }, + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(getFile).not.toHaveBeenCalled(); + }); + + it("suppresses an accepted peer-bot album before downloading it", async () => { + loadConfig.mockReturnValue({ + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const getFile = vi.fn(async () => ({ file_path: "photos/suppressed.jpg" })); + const chat = { id: -9_876_543_239, type: "group", title: "Bot album cooldown" }; + const peer = { id: 8_765_432_129, is_bot: true, username: "peer_bot" }; + const me = { id: 7_654_321_000, is_bot: true, username: "openclaw_bot" }; + + await handler({ + me, + getFile, + message: { + chat, + from: peer, + text: "@openclaw_bot prime album loop budget", + date: 1_736_380_800, + message_id: 1, + }, + }); + await handler({ + me, + getFile, + message: { + chat, + from: peer, + media_group_id: "suppressed-peer-album", + caption: "@openclaw_bot expensive album", + photo: [{ file_id: "photo", file_unique_id: "photo-unique", width: 1, height: 1 }], + date: 1_736_380_800, + message_id: 2, + }, + }); + await new Promise((resolve) => { + setTimeout(resolve, TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs * 2); + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(getFile).not.toHaveBeenCalled(); + }); + + it("does not answer or spend loop budget when deferred peer-bot media fails", async () => { + loadConfig.mockReturnValue({ + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_213, type: "group", title: "Bot media failure" }, + from: { id: 8_765_432_103, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => ({}), + }; + + await handler({ + ...contextBase, + message: { + ...messageBase, + message_id: 1, + voice: { duration: 1, file_id: "broken", file_unique_id: "broken-unique" }, + }, + }); + await handler({ + ...contextBase, + message: { + ...messageBase, + message_id: 2, + text: "@openclaw_bot still respond", + }, + }); + + expect(sendMessageSpy).not.toHaveBeenCalledWith( + messageBase.chat.id, + expect.stringContaining("Failed to download media"), + expect.anything(), + ); + expect(JSON.stringify(replySpy.mock.calls)).toContain("still respond"); + }); + + it("applies peer-bot loop protection after buffered audio-album admission", async () => { + loadConfig.mockReturnValue({ + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_214, type: "group", title: "Bot audio album" }, + from: { id: 8_765_432_104, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + + await handler({ + ...contextBase, + message: { + ...messageBase, + message_id: 1, + media_group_id: "peer-audio-album", + caption: "@openclaw_bot album response", + audio: { duration: 1, file_id: "audio", file_unique_id: "audio-unique" }, + }, + }); + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(1)); + await handler({ + ...contextBase, + message: { + ...messageBase, + message_id: 2, + text: "@openclaw_bot second response", + }, + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(JSON.stringify(replySpy.mock.calls)).toContain("album response"); + }); + + it("keeps later peer-bot turns behind deferred album admission", async () => { + loadConfig.mockReturnValue({ + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_218, type: "group", title: "Ordered bot album" }, + from: { id: 8_765_432_108, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + + await handler({ + ...contextBase, + message: { + ...messageBase, + message_id: 1, + media_group_id: "ordered-audio-album", + caption: "@openclaw_bot album first", + audio: { duration: 1, file_id: "audio", file_unique_id: "audio-unique" }, + }, + }); + await handler({ + ...contextBase, + message: { + ...messageBase, + message_id: 2, + text: "@openclaw_bot later plain turn", + }, + }); + + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(1)); + expect(JSON.stringify(replySpy.mock.calls)).toContain("album first"); + expect(JSON.stringify(replySpy.mock.calls)).not.toContain("later plain turn"); + }); + + it("admits a mixed peer-bot media album only once", async () => { + loadConfig.mockReturnValue({ + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }), + ); + const messageBase = { + chat: { id: -9_876_543_215, type: "group", title: "Bot mixed album" }, + from: { id: 8_765_432_105, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + media_group_id: "peer-mixed-album", + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => ({ file_path: "photos/mixed.jpg" }), + }; + + try { + await handler({ + ...contextBase, + message: { + ...messageBase, + message_id: 1, + photo: [{ file_id: "photo", file_unique_id: "photo-unique", width: 1, height: 1 }], + }, + }); + await handler({ + ...contextBase, + message: { + ...messageBase, + message_id: 2, + caption: "@openclaw_bot mixed album response", + audio: { duration: 1, file_id: "audio", file_unique_id: "audio-unique" }, + }, + }); + + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(1)); + expect(JSON.stringify(replySpy.mock.calls)).toContain("mixed album response"); + await handler({ + ...contextBase, + message: { + chat: messageBase.chat, + from: messageBase.from, + date: messageBase.date, + message_id: 3, + text: "@openclaw_bot after mixed album", + }, + }); + expect(replySpy).toHaveBeenCalledTimes(1); + } finally { + fetchSpy.mockRestore(); + } + }); + + it("spends peer-bot loop budget once for a photo album", async () => { + loadConfig.mockReturnValue({ + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }), + ); + const messageBase = { + chat: { id: -9_876_543_216, type: "group", title: "Bot photo album" }, + from: { id: 8_765_432_106, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + media_group_id: "peer-photo-album", + }; + const getFileSpy = vi.fn(async () => ({ file_path: "photos/album.jpg" })); + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: getFileSpy, + }; + + try { + await handler({ + ...contextBase, + message: { + ...messageBase, + message_id: 1, + caption: "@openclaw_bot photo album response", + photo: [{ file_id: "photo-1", file_unique_id: "photo-unique-1", width: 1, height: 1 }], + }, + }); + await handler({ + ...contextBase, + message: { + ...messageBase, + message_id: 2, + photo: [{ file_id: "photo-2", file_unique_id: "photo-unique-2", width: 1, height: 1 }], + }, + }); + + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(1)); + expect(getFileSpy).toHaveBeenCalledTimes(2); + expect(JSON.stringify(replySpy.mock.calls)).toContain("photo album response"); + await handler({ + ...contextBase, + message: { + chat: messageBase.chat, + from: messageBase.from, + date: messageBase.date, + message_id: 3, + text: "@openclaw_bot second response", + }, + }); + expect(replySpy).toHaveBeenCalledTimes(1); + } finally { + fetchSpy.mockRestore(); + } + }); + + it("spends peer-bot loop budget once for buffered text fragments", async () => { + loadConfig.mockReturnValue({ + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_217, type: "group", title: "Bot text fragments" }, + from: { id: 8_765_432_107, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + const [firstFragment, finalFragment] = frameStandardTextFragments( + "A".repeat(3_998), + "fragment-tail-unique", + ); + + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 1, text: firstFragment }, + }); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 3, text: finalFragment }, + }); + + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(1)); + const bufferedCalls = JSON.stringify(replySpy.mock.calls); + expect(bufferedCalls).toContain("fragment-tail-unique"); + expect(bufferedCalls).not.toContain("\u2060"); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 4, text: "second logical turn" }, + }); + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("does not cache sibling fragments when loop protection suppresses their combined turn", async () => { + loadConfig.mockReturnValue({ + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const chat = { id: -9_876_543_229, type: "group", title: "Suppressed fragments" }; + const peer = { id: 8_765_432_119, is_bot: true, username: "peer_bot" }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + const [firstFragment, finalFragment] = frameStandardTextFragments( + "A".repeat(3_998), + "suppressed-tail-unique", + ); + + await handler({ + ...contextBase, + message: { chat, from: peer, date: 1_736_380_800, message_id: 1, text: "prime budget" }, + }); + await handler({ + ...contextBase, + message: { + chat, + from: peer, + date: 1_736_380_800, + message_id: 2, + text: firstFragment, + }, + }); + await handler({ + ...contextBase, + message: { + chat, + from: peer, + date: 1_736_380_800, + message_id: 3, + text: finalFragment, + }, + }); + expect(replySpy).toHaveBeenCalledTimes(1); + await handler({ + ...contextBase, + message: { + chat, + from: { id: 111, is_bot: false, first_name: "Ada" }, + date: 1_736_380_800, + message_id: 4, + text: "human follow-up", + }, + }); + + expect(replySpy).toHaveBeenCalledTimes(2); + expect(JSON.stringify(replySpy.mock.calls[1])).not.toContain("suppressed-tail-unique"); + }); + + it("keeps marked peer-bot fragments together beyond the human paste window", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ + token: "tok", + testTimings: { + ...TELEGRAM_TEST_TIMINGS, + peerBotTextFragmentGapMs: 120, + textFragmentGapMs: 20, + }, + }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_226, type: "group", title: "Delayed bot fragments" }, + from: { id: 8_765_432_116, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + const [firstFragment, finalFragment] = frameStandardTextFragments( + "A".repeat(3_998), + "delayed-tail", + ); + + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 1, text: firstFragment }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 40); + }); + expect(replySpy).not.toHaveBeenCalled(); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 3, text: finalFragment }, + }); + + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(1)); + expect(replySpy.mock.calls[0]?.[0].BodyForAgent).toBe(`${"A".repeat(3_998)}delayed-tail`); + await handler({ + ...contextBase, + message: { + ...messageBase, + from: { id: 111, is_bot: false, first_name: "Ada" }, + message_id: 4, + text: "human after framed message", + }, + }); + expect(replySpy).toHaveBeenCalledTimes(2); + expect(JSON.stringify(replySpy.mock.calls[1])).toContain("delayed-tail"); + expect(JSON.stringify(replySpy.mock.calls[1])).not.toContain("\u2060"); + }); + + it("preserves interleaved framed peer-bot turns", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_228, type: "group", title: "Framed bot turns" }, + from: { id: 8_765_432_118, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + const [firstBatchStart, firstBatchEnd] = frameStandardTextFragments( + "A".repeat(3_998), + "first-tail", + ); + const [secondBatchStart, secondBatchEnd] = frameStandardTextFragments( + "B".repeat(3_998), + "second-tail", + ); + + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 1, text: firstBatchStart }, + }); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 2, text: secondBatchStart }, + }); + expect(replySpy).not.toHaveBeenCalled(); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 3, text: secondBatchEnd }, + }); + expect(replySpy).not.toHaveBeenCalled(); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 4, text: firstBatchEnd }, + }); + + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(2)); + expect(replySpy.mock.calls.map((call) => call[0].BodyForAgent)).toEqual([ + `${"A".repeat(3_998)}first-tail`, + `${"B".repeat(3_998)}second-tail`, + ]); + }); + + it("preserves a framed peer-bot turn across an unrelated short message", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_236, type: "group", title: "Framed and short bot turns" }, + from: { id: 8_765_432_126, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + const [batchStart, batchEnd] = frameStandardTextFragments("A".repeat(3_998), "framed-tail"); + + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 1, text: batchStart }, + }); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 2, text: "independent short turn" }, + }); + expect(replySpy).not.toHaveBeenCalled(); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 3, text: batchEnd }, + }); + + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(2)); + expect(replySpy.mock.calls.map((call) => call[0].BodyForAgent)).toEqual([ + `${"A".repeat(3_998)}framed-tail`, + "independent short turn", + ]); + }); + + it("applies loop protection in admission order when framed timestamps regress", async () => { + loadConfig.mockReturnValue({ + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_237, type: "group", title: "Regressed bot timestamps" }, + from: { id: 8_765_432_127, is_bot: true, username: "peer_bot" }, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + const [firstStart, firstEnd] = frameStandardTextFragments("A".repeat(3_998), "admitted-first"); + const [secondStart, secondEnd] = frameStandardTextFragments( + "B".repeat(3_998), + "completed-first", + ); + + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 1, date: 300, text: firstStart }, + }); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 2, date: 100, text: secondStart }, + }); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 3, date: 101, text: secondEnd }, + }); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 4, date: 400, text: firstEnd }, + }); + + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => { + setTimeout(resolve, TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs * 2); + }); + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy.mock.calls[0]?.[0].BodyForAgent).toBe(`${"A".repeat(3_998)}admitted-first`); + }); + + it("bounds incomplete framed peer-bot batches per sender", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const chat = { id: -9_876_543_233, type: "group", title: "Bounded framed batches" }; + const from = { id: 8_765_432_123, is_bot: true, username: "peer_bot" }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + const batches = ["A", "B", "C", "D", "E"].map((label) => ({ + label, + frames: frameStandardTextFragments(label.repeat(3_998), `${label}-tail`), + })); + + for (const [index, batch] of batches.entries()) { + await handler({ + ...contextBase, + message: { + chat, + from, + date: 1_736_380_800, + message_id: index + 1, + text: batch.frames[0], + }, + }); + } + expect(replySpy).not.toHaveBeenCalled(); + for (const [index, batch] of batches.entries()) { + await handler({ + ...contextBase, + message: { + chat, + from, + date: 1_736_380_800, + message_id: batches.length + index + 1, + text: batch.frames[1], + }, + }); + } + + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(4)); + expect(replySpy.mock.calls.map((call) => call[0].BodyForAgent)).toEqual( + batches.slice(1).map((batch) => `${batch.label.repeat(3_998)}${batch.label}-tail`), + ); + }); + + it("keeps an unmarked turn separate from an incomplete framed peer-bot batch", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ + token: "tok", + testTimings: { ...TELEGRAM_TEST_TIMINGS, peerBotTextFragmentGapMs: 10_000 }, + }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_230, type: "group", title: "Incomplete framed batch" }, + from: { id: 8_765_432_120, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + const [firstFragment, lastFragment] = frameStandardTextFragments( + "A".repeat(3_998), + "framed-tail", + ); + + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 1, text: firstFragment }, + }); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 2, text: "ordinary next turn" }, + }); + expect(replySpy).not.toHaveBeenCalled(); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 3, text: lastFragment }, + }); + + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(2)); + expect(replySpy.mock.calls.map((call) => call[0].BodyForAgent)).toEqual([ + `${"A".repeat(3_998)}framed-tail`, + "ordinary next turn", + ]); + }); + + it("retires an incomplete framed batch before delivering its failure fallback", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ + token: "tok", + testTimings: { ...TELEGRAM_TEST_TIMINGS, peerBotTextFragmentGapMs: 10_000 }, + }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_238, type: "group", title: "Aborted framed batch" }, + from: { id: 8_765_432_128, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + const [firstFragment] = frameStandardTextFragments("A".repeat(3_998), "missing-tail"); + const abortFragment = buildTelegramStandardFragmentAbort(firstFragment); + if (!abortFragment) { + throw new Error("expected framed batch abort marker"); + } + + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 1, text: firstFragment }, + }); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 2, text: abortFragment }, + }); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 3, text: "delivery failed" }, + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy.mock.calls[0]?.[0].BodyForAgent).toBe("delivery failed"); + }); + + it("drops an oversized framed peer-bot batch as one invalid turn", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const chat = { id: -9_876_543_231, type: "group", title: "Oversized framed batch" }; + const peer = { id: 8_765_432_121, is_bot: true, username: "peer_bot" }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + const [startFrame, continuationFrame, endFrame] = frameStandardTextFragments( + "A".repeat(3_998), + "A".repeat(3_998), + "oversized-tail-unique", + ); + const startPrefix = startFrame.slice(0, -3_998); + const continuationPrefix = continuationFrame.slice(0, -3_998); + const endPrefix = endFrame.slice(0, -"oversized-tail-unique".length); + + for (let index = 0; index < TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS; index += 1) { + await handler({ + ...contextBase, + message: { + chat, + from: peer, + date: 1_736_380_800, + message_id: index + 1, + text: `${index === 0 ? startPrefix : continuationPrefix}${"A".repeat(3_998)}`, + }, + }); + } + await handler({ + ...contextBase, + message: { + chat, + from: peer, + date: 1_736_380_800, + message_id: TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS + 1, + text: `${endPrefix}oversized-tail-unique`, + }, + }); + expect(replySpy).not.toHaveBeenCalled(); + await handler({ + ...contextBase, + message: { + chat, + from: { id: 111, is_bot: false, first_name: "Ada" }, + date: 1_736_380_800, + message_id: TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS + 2, + text: "human follow-up", + }, + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(JSON.stringify(replySpy.mock.calls[0])).not.toContain("oversized-tail-unique"); + }); + + it("keeps adjacent unmarked peer-bot messages as separate turns", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_225, type: "group", title: "Separate bot turns" }, + from: { id: 8_765_432_115, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 1, text: "A".repeat(4_000) }, + }); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 2, text: "separate bot turn" }, + }); + + expect(replySpy).toHaveBeenCalledTimes(2); + expect(replySpy.mock.calls.map((call) => call[0].BodyForAgent)).toEqual([ + "A".repeat(4_000), + "separate bot turn", + ]); + }); + + it("keeps adjacent sender-chat fragments in one channel-post turn", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_234, type: "group", title: "Channel post fragments" }, + from: { id: 7_777_777_777, is_bot: true, first_name: "Channel" }, + sender_chat: { id: -1_000_777_777_777, type: "channel", title: "Updates" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 1, text: "channel-start".padEnd(4_000, "A") }, + }); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 2, text: "channel-tail" }, + }); + + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(1)); + expect(replySpy.mock.calls[0]?.[0].BodyForAgent).toBe( + `${"channel-start".padEnd(4_000, "A")}channel-tail`, + ); + }); + + it("keeps peer-bot abort controls out of buffered text fragments", async () => { + loadConfig.mockReturnValue({ + commands: { native: false }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ + token: "tok", + testTimings: { ...TELEGRAM_TEST_TIMINGS, textFragmentGapMs: 5_000 }, + }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_224, type: "group", title: "Bot fragment abort" }, + from: { id: 8_765_432_114, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + + await handler({ + ...contextBase, + message: { + ...messageBase, + message_id: 1, + text: "long-buffered-request".padEnd(4_000, "A"), + }, + }); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 2, text: "stop" }, + }); + + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(1)); + expect(replySpy.mock.calls[0]?.[0]).toMatchObject({ + BodyForAgent: "stop", + MessageSid: "2", + }); + }); + + it("counts peer-bot abort controls toward loop protection without ordering them", async () => { + loadConfig.mockReturnValue({ + commands: { native: false }, + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_227, type: "group", title: "Bot abort loop" }, + from: { id: 8_765_432_117, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 1, text: "stop" }, + }); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 2, text: "stop" }, + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy.mock.calls[0]?.[0].MessageSid).toBe("1"); + }); + + it("cancels buffered peer media before suppressing a repeated plain stop", async () => { + loadConfig.mockReturnValue({ + commands: { native: false }, + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_235, type: "group", title: "Bot plain stop media" }, + from: { id: 8_765_432_125, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 1, text: "stop" }, + }); + await handler({ + ...contextBase, + message: { + ...messageBase, + message_id: 2, + media_group_id: "plain-stop-album", + caption: "canceled plain-stop album", + audio: { duration: 1, file_id: "audio", file_unique_id: "audio-unique" }, + }, + }); + await handler({ + ...contextBase, + message: { ...messageBase, message_id: 3, text: "stop" }, + }); + await new Promise((resolve) => { + setTimeout(resolve, TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs * 2); + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const calls = JSON.stringify(replySpy.mock.calls); + expect(calls).toContain('"MessageSid":"1"'); + expect(calls).not.toContain("canceled plain-stop album"); + }); + + it("rechecks peer-bot loop admission when a text-fragment batch rolls over", async () => { + loadConfig.mockReturnValue({ + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_220, type: "group", title: "Bot fragment rollover" }, + from: { id: 8_765_432_110, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + + for (let messageId = 1; messageId <= 13; messageId += 1) { + await handler({ + ...contextBase, + message: { + ...messageBase, + message_id: messageId, + text: + messageId === 13 + ? "suppressed-rollover-unique".padStart(4_000, "A") + : String(messageId).padStart(4_000, "A"), + }, + }); + } + + // The 13th fragment starts a second batch; wait for its suppressed flush too + // so no timer-backed work leaks into the next test. + await new Promise((resolve) => { + setTimeout(resolve, TELEGRAM_TEST_TIMINGS.textFragmentGapMs * 2); + }); + expect(replySpy).toHaveBeenCalledTimes(1); + await handler({ + ...contextBase, + message: { + ...messageBase, + from: { id: 111, is_bot: false, first_name: "Ada" }, + message_id: 14, + text: "human after suppressed rollover", + }, + }); + expect(replySpy).toHaveBeenCalledTimes(2); + expect(JSON.stringify(replySpy.mock.calls[1])).not.toContain("suppressed-rollover-unique"); + }); + + it("releases rolled-over peer-bot admission after mention rejection", async () => { + loadConfig.mockReturnValue({ + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageBase = { + chat: { id: -9_876_543_221, type: "group", title: "Bot mention rollover" }, + from: { id: 8_765_432_111, is_bot: true, username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + + for (let messageId = 1; messageId <= 13; messageId += 1) { + await handler({ + ...contextBase, + message: { + ...messageBase, + message_id: messageId, + text: String(messageId).padStart(4_000, "A"), + }, + }); + } + await new Promise((resolve) => { + setTimeout(resolve, TELEGRAM_TEST_TIMINGS.textFragmentGapMs * 2); + }); + await handler({ + ...contextBase, + message: { + ...messageBase, + message_id: 14, + text: "@openclaw_bot admitted after rollover", + }, + }); + + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(1)); + expect(JSON.stringify(replySpy.mock.calls)).toContain("admitted after rollover"); + }); + + it("keeps deferred peer-bot album context inside its DM topic", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }); + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const editedHandler = getOnHandler("edited_message") as ( + ctx: Record, + ) => Promise; + const chat = { + id: 9_876_543_219, + type: "private", + title: "Deferred topic context", + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot", has_topics_enabled: true }, + getFile: async () => null, + }; + + await editedHandler({ + ...contextBase, + editedMessage: { + chat, + from: { id: 111, is_bot: false, username: "human" }, + date: 1_736_380_800, + edit_date: 1_736_380_801, + message_thread_id: 202, + message_id: 1, + text: "sibling-topic-secret", + }, + }); + + await handler({ + ...contextBase, + message: { + chat, + from: { id: 8_765_432_109, is_bot: true, username: "peer_bot" }, + date: 1_736_380_801, + message_thread_id: 101, + message_id: 2, + media_group_id: "topic-album", + caption: "topic-local album", + audio: { duration: 1, file_id: "audio", file_unique_id: "audio-topic" }, + }, + }); + + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(1)); + expect(JSON.stringify(replySpy.mock.calls)).not.toContain("sibling-topic-secret"); + }); + it("reuses the grammY throttler for the same token", () => { createTelegramBot({ token: "tok" }); createTelegramBot({ token: "tok" }); @@ -4393,6 +6016,7 @@ describe("createTelegramBot", () => { replySpy.mockResolvedValue({ text: "a".repeat(TELEGRAM_RICH_TEXT_LIMIT + 1024), replyToId: String(messageId), + replyToIdSource: "implicit", }); loadConfig.mockReturnValue({ channels: { @@ -4548,6 +6172,346 @@ describe("createTelegramBot", () => { expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); }); + it("disables block streaming for peer-bot native commands", async () => { + commandSpy.mockClear(); + sendMessageSpy.mockClear(); + dispatchReplyWithBufferedBlockDispatcher.mockClear(); + replySpy.mockResolvedValue({ text: "Compacted" }); + + loadConfig.mockReturnValue({ + commands: { native: true }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + streaming: { block: { enabled: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const compactHandler = commandSpy.mock.calls.find((call) => call[0] === "compact")?.[1] as + | ((ctx: Record) => Promise) + | undefined; + if (!compactHandler) { + throw new Error("compact command handler missing"); + } + + await compactHandler({ + message: { + chat: { id: -1234, type: "group", title: "Bot commands" }, + from: { id: 42, is_bot: true, first_name: "Peer", username: "peer_bot" }, + text: "/compact", + date: 1_736_380_800, + message_id: 5, + }, + me: { id: 99, username: "openclaw_bot" }, + match: "", + }); + + expect(dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0].replyOptions).toMatchObject({ + disableBlockStreaming: true, + }); + expect(sendMessageSpy.mock.calls[0]?.[2]).toMatchObject({ + reply_to_message_id: 5, + allow_sending_without_reply: true, + }); + }); + + it("serializes peer-bot native commands behind buffered message admission", async () => { + commandSpy.mockClear(); + replySpy.mockClear(); + loadConfig.mockReturnValue({ + commands: { native: true }, + channels: { + defaults: { + botLoopProtection: { + enabled: true, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const messageHandler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + const compactHandler = commandSpy.mock.calls.find((call) => call[0] === "compact")?.[1] as + | ((ctx: Record) => Promise) + | undefined; + if (!compactHandler) { + throw new Error("compact command handler missing"); + } + const messageBase = { + chat: { id: -9_876_543_223, type: "group", title: "Bot command admission" }, + from: { id: 8_765_432_113, is_bot: true, first_name: "Peer", username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + + await messageHandler({ + ...contextBase, + message: { + ...messageBase, + message_id: 1, + text: "buffered-message-unique".padEnd(4_000, "A"), + }, + }); + await compactHandler({ + ...contextBase, + message: { ...messageBase, message_id: 2, text: "/compact" }, + match: "", + }); + + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(1)); + const calls = JSON.stringify(replySpy.mock.calls); + expect(calls).toContain("buffered-message-unique"); + expect(calls).not.toContain("/compact"); + }); + + it("lets peer-bot stop cancel buffered admission without waiting for its timeout", async () => { + commandSpy.mockClear(); + replySpy.mockClear(); + replySpy.mockResolvedValue({ text: "stopped" }); + loadConfig.mockReturnValue({ + commands: { native: true }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ + token: "tok", + testTimings: { ...TELEGRAM_TEST_TIMINGS, peerBotTextFragmentGapMs: 60_000 }, + }); + const messageHandler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + const stopHandler = commandSpy.mock.calls.find((call) => call[0] === "stop")?.[1] as + | ((ctx: Record) => Promise) + | undefined; + if (!stopHandler) { + throw new Error("stop command handler missing"); + } + const messageBase = { + chat: { id: -9_876_543_228, type: "group", title: "Bot stop admission" }, + from: { id: 8_765_432_118, is_bot: true, first_name: "Peer", username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + + await messageHandler({ + ...contextBase, + message: { + ...messageBase, + message_id: 1, + text: "buffered-stop-target".padEnd(4_000, "A"), + }, + }); + let timeout: ReturnType | undefined; + const completion = await Promise.race([ + stopHandler({ + ...contextBase, + message: { ...messageBase, message_id: 2, text: "/stop" }, + match: "", + }).then(() => "completed" as const), + new Promise<"timed-out">((resolve) => { + timeout = setTimeout(() => resolve("timed-out"), 500); + }), + ]).finally(() => clearTimeout(timeout)); + + expect(completion).toBe("completed"); + await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(1)); + const calls = JSON.stringify(replySpy.mock.calls); + expect(calls).toContain("/stop"); + expect(calls).not.toContain("buffered-stop-target"); + }); + + it("does not cache a rolled-over peer fragment canceled by stop", async () => { + commandSpy.mockClear(); + replySpy.mockClear(); + replySpy.mockResolvedValue({ text: "stopped" }); + loadConfig.mockReturnValue({ + commands: { native: true }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ + token: "tok", + testTimings: { ...TELEGRAM_TEST_TIMINGS, peerBotTextFragmentGapMs: 60_000 }, + }); + const messageHandler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + const stopHandler = commandSpy.mock.calls.find((call) => call[0] === "stop")?.[1] as + | ((ctx: Record) => Promise) + | undefined; + if (!stopHandler) { + throw new Error("stop command handler missing"); + } + const messageBase = { + chat: { id: -9_876_543_233, type: "group", title: "Bot rollover stop" }, + from: { id: 8_765_432_123, is_bot: true, first_name: "Peer", username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => null, + }; + + for (let messageId = 1; messageId <= 13; messageId += 1) { + await messageHandler({ + ...contextBase, + message: { + ...messageBase, + message_id: messageId, + text: + messageId === 13 + ? "canceled-rollover-unique".padStart(4_000, "A") + : String(messageId).padStart(4_000, "A"), + }, + }); + } + await stopHandler({ + ...contextBase, + message: { ...messageBase, message_id: 14, text: "/stop" }, + match: "", + }); + await messageHandler({ + ...contextBase, + message: { + ...messageBase, + from: { id: 111, is_bot: false, first_name: "Ada" }, + message_id: 15, + text: "human after canceled rollover", + }, + }); + + const humanCall = replySpy.mock.calls.find( + (call) => call[0].BodyForAgent === "human after canceled rollover", + ); + expect(humanCall).toBeDefined(); + expect(JSON.stringify(humanCall)).not.toContain("canceled-rollover-unique"); + }); + + it("keeps a flushed peer-bot media admission cancellable during download", async () => { + commandSpy.mockClear(); + replySpy.mockClear(); + replySpy.mockResolvedValue({ text: "stopped" }); + loadConfig.mockReturnValue({ + commands: { native: true }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const messageHandler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + const stopHandler = commandSpy.mock.calls.find((call) => call[0] === "stop")?.[1] as + | ((ctx: Record) => Promise) + | undefined; + if (!stopHandler) { + throw new Error("stop command handler missing"); + } + const downloadStarted = createDeferred(); + const finishDownload = createDeferred(); + const downloadFinished = createDeferred(); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }), + ); + const messageBase = { + chat: { id: -9_876_543_240, type: "group", title: "Bot flushed stop" }, + from: { id: 8_765_432_130, is_bot: true, first_name: "Peer", username: "peer_bot" }, + date: 1_736_380_800, + }; + const contextBase = { + me: { id: 7_654_321_000, username: "openclaw_bot" }, + getFile: async () => { + downloadStarted.resolve(); + await finishDownload.promise; + downloadFinished.resolve(); + return { file_path: "photos/flushed-stop.jpg" }; + }, + }; + + try { + await messageHandler({ + ...contextBase, + message: { + ...messageBase, + message_id: 1, + media_group_id: "flushed-stop-album", + caption: "@openclaw_bot canceled media turn", + photo: [{ file_id: "photo", file_unique_id: "photo-unique", width: 1, height: 1 }], + }, + }); + await downloadStarted.promise; + let timeout: ReturnType | undefined; + const completion = await Promise.race([ + stopHandler({ + ...contextBase, + message: { ...messageBase, message_id: 2, text: "/stop" }, + match: "", + }).then(() => "completed" as const), + new Promise<"timed-out">((resolve) => { + timeout = setTimeout(() => resolve("timed-out"), 500); + }), + ]).finally(() => clearTimeout(timeout)); + expect(completion).toBe("completed"); + finishDownload.resolve(); + + await downloadFinished.promise; + await flushTelegramTestMicrotasks(); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(fetchSpy).not.toHaveBeenCalled(); + const calls = JSON.stringify(replySpy.mock.calls); + expect(calls).not.toContain("canceled media turn"); + } finally { + finishDownload.resolve(); + fetchSpy.mockRestore(); + } + }); + it("threads native command replies inside topics", async () => { commandSpy.mockClear(); sendMessageSpy.mockClear(); diff --git a/extensions/telegram/src/bot.types.ts b/extensions/telegram/src/bot.types.ts index 355fea6fccf0..c07f0917adcb 100644 --- a/extensions/telegram/src/bot.types.ts +++ b/extensions/telegram/src/bot.types.ts @@ -29,6 +29,7 @@ export type TelegramBotOptions = { }; testTimings?: { mediaGroupFlushMs?: number; + peerBotTextFragmentGapMs?: number; textFragmentGapMs?: number; }; /** Pre-resolved Telegram transport to reuse across bot instances. If not provided, creates a new one. */ diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 302dd49480b8..e51c1d43ff81 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -31,10 +31,15 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { resolveTelegramInlineButtons, type TelegramInlineButtons } from "../button-types.js"; import { splitTelegramCaption } from "../caption.js"; +import { markTelegramDeliveryErrorVisible } from "../delivery-error.js"; import { renderTelegramHtmlText } from "../format.js"; import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js"; import { splitTelegramRichMarkdownChunks, TELEGRAM_RICH_TEXT_LIMIT } from "../rich-message.js"; import { buildInlineKeyboard } from "../send.js"; +import { + buildTelegramStandardFragmentAbort, + buildTelegramStandardTextChunks, +} from "../standard-text.js"; import { resolveTelegramVoiceSend } from "../voice.js"; import { buildTelegramSendParams, @@ -73,6 +78,8 @@ type TelegramReplyQuoteForSend = { type TelegramTextChunk = { text: string; + plainText?: string; + textMode?: "html"; }; type ChunkTextFn = (markdown: string) => TelegramTextChunk[]; @@ -81,7 +88,20 @@ function buildChunkTextResolver(params: { textLimit: number; chunkMode: ChunkMode; tableMode?: MarkdownTableMode; + standardMessages?: boolean; }): ChunkTextFn { + if (params.standardMessages) { + return (markdown: string) => + buildTelegramStandardTextChunks(markdown, { tableMode: params.tableMode }).map((chunk) => + Object.assign( + { + text: chunk.htmlText ?? chunk.plainText, + plainText: chunk.plainText, + }, + chunk.htmlText ? { textMode: "html" as const } : {}, + ), + ); + } return (markdown: string) => { return splitTelegramRichMarkdownChunks(markdown, params.textLimit, params.chunkMode).map( (text) => ({ @@ -102,6 +122,59 @@ function filterEmptyTelegramTextChunks(chunks: reado return chunks.filter((chunk) => chunk.text.trim().length > 0); } +function resolveTelegramTextChunkReplyToMode( + chunks: readonly TelegramTextChunk[], + replyToMode: ReplyToMode, +): ReplyToMode { + return chunks.some((chunk) => chunk.plainText !== undefined) ? "all" : replyToMode; +} + +function isFramedStandardTextBatch(chunks: readonly TelegramTextChunk[]): boolean { + return chunks.length > 1 && chunks.every((chunk) => chunk.plainText !== undefined); +} + +function restoreIncompleteFramedBatchProgress( + progress: DeliveryProgress, + before: DeliveryProgress, +): void { + progress.hasReplied = before.hasReplied; + progress.hasDelivered = before.hasDelivered; + progress.deliveredCount = before.deliveredCount; +} + +function madeFramedBatchProgress(progress: DeliveryProgress, before: DeliveryProgress): boolean { + return ( + progress.deliveredCount > before.deliveredCount || + (progress.hasDelivered && !before.hasDelivered) + ); +} + +async function retireIncompleteFramedBatch(params: { + bot: Bot; + chatId: string; + runtime: RuntimeEnv; + thread?: TelegramThreadSpec | null; + replyToMessageId?: number; + chunks: readonly TelegramTextChunk[]; + silent?: boolean; +}): Promise { + const firstChunk = params.chunks[0]; + const abortText = buildTelegramStandardFragmentAbort( + firstChunk?.plainText ?? firstChunk?.text ?? "", + ); + if (!abortText) { + return; + } + await sendTelegramText(params.bot, params.chatId, abortText, params.runtime, { + thread: params.thread, + replyToMessageId: params.replyToMessageId, + standardMessage: { plainText: abortText }, + silent: params.silent, + }).catch((error: unknown) => { + logVerbose(`telegram framed batch abort failed: ${String(error)}`); + }); +} + function resolveReplyQuoteForSend(params: { replyToId?: number; replyQuoteByMessageId?: TelegramNativeQuoteCandidateByMessageId; @@ -162,38 +235,65 @@ async function deliverTextReply(params: { }): Promise { let firstDeliveredMessageId: number | undefined; const chunks = filterEmptyTelegramTextChunks(params.chunkText(params.replyText)); - await sendChunkedTelegramReplyText({ - chunks, - progress: params.progress, - replyToId: params.replyToId, - replyToMode: params.replyToMode, - replyMarkup: params.replyMarkup, - replyQuoteText: params.replyQuoteText, - markDelivered, - sendChunk: async ({ chunk, replyToMessageId, replyMarkup, replyQuoteText }) => { - const messageId = await sendTelegramText( - params.bot, - params.chatId, - chunk.text, - params.runtime, - { - replyToMessageId, - replyQuoteMessageId: params.replyQuoteMessageId, - replyQuoteText, - replyQuotePosition: params.replyQuotePosition, - replyQuoteEntities: params.replyQuoteEntities, + const progressBeforeBatch = { ...params.progress }; + try { + await sendChunkedTelegramReplyText({ + chunks, + progress: params.progress, + replyToId: params.replyToId, + replyToMode: resolveTelegramTextChunkReplyToMode(chunks, params.replyToMode), + replyMarkup: params.replyMarkup, + replyQuoteText: params.replyQuoteText, + markDelivered, + sendChunk: async ({ chunk, replyToMessageId, replyMarkup, replyQuoteText }) => { + const messageId = await sendTelegramText( + params.bot, + params.chatId, + chunk.text, + params.runtime, + { + replyToMessageId, + replyQuoteMessageId: params.replyQuoteMessageId, + replyQuoteText, + replyQuotePosition: params.replyQuotePosition, + replyQuoteEntities: params.replyQuoteEntities, + thread: params.thread, + textMode: chunk.textMode ?? "markdown", + ...(chunk.plainText !== undefined + ? { standardMessage: { plainText: chunk.plainText } } + : {}), + linkPreview: params.linkPreview, + silent: params.silent, + replyMarkup, + }, + ); + if (firstDeliveredMessageId == null) { + firstDeliveredMessageId = messageId; + } + }, + }); + } catch (error) { + if (isFramedStandardTextBatch(chunks)) { + if (madeFramedBatchProgress(params.progress, progressBeforeBatch)) { + await retireIncompleteFramedBatch({ + bot: params.bot, + chatId: params.chatId, + runtime: params.runtime, thread: params.thread, - textMode: "markdown", - linkPreview: params.linkPreview, + replyToMessageId: params.replyToId, + chunks, silent: params.silent, - replyMarkup, - }, - ); - if (firstDeliveredMessageId == null) { - firstDeliveredMessageId = messageId; + }); } - }, - }); + // A peer cannot consume a prefix without the end frame. Keep the logical + // turn undelivered so the caller can send a visible terminal fallback. + restoreIncompleteFramedBatchProgress(params.progress, progressBeforeBatch); + } + if (params.progress.hasDelivered) { + throw markTelegramDeliveryErrorVisible(error); + } + throw error; + } return firstDeliveredMessageId; } @@ -212,24 +312,49 @@ async function sendPendingFollowUpText(params: { progress: DeliveryProgress; }): Promise { const chunks = filterEmptyTelegramTextChunks(params.chunkText(params.text)); - await sendChunkedTelegramReplyText({ - chunks, - progress: params.progress, - replyToId: params.replyToId, - replyToMode: params.replyToMode, - replyMarkup: params.replyMarkup, - markDelivered, - sendChunk: async ({ chunk, replyToMessageId, replyMarkup }) => { - await sendTelegramText(params.bot, params.chatId, chunk.text, params.runtime, { - replyToMessageId, - thread: params.thread, - textMode: "markdown", - linkPreview: params.linkPreview, - silent: params.silent, - replyMarkup, - }); - }, - }); + const progressBeforeBatch = { ...params.progress }; + try { + await sendChunkedTelegramReplyText({ + chunks, + progress: params.progress, + replyToId: params.replyToId, + replyToMode: resolveTelegramTextChunkReplyToMode(chunks, params.replyToMode), + replyMarkup: params.replyMarkup, + markDelivered, + sendChunk: async ({ chunk, replyToMessageId, replyMarkup }) => { + await sendTelegramText(params.bot, params.chatId, chunk.text, params.runtime, { + replyToMessageId, + thread: params.thread, + textMode: chunk.textMode ?? "markdown", + ...(chunk.plainText !== undefined + ? { standardMessage: { plainText: chunk.plainText } } + : {}), + linkPreview: params.linkPreview, + silent: params.silent, + replyMarkup, + }); + }, + }); + } catch (error) { + if (isFramedStandardTextBatch(chunks)) { + if (madeFramedBatchProgress(params.progress, progressBeforeBatch)) { + await retireIncompleteFramedBatch({ + bot: params.bot, + chatId: params.chatId, + runtime: params.runtime, + thread: params.thread, + replyToMessageId: params.replyToId, + chunks, + silent: params.silent, + }); + } + restoreIncompleteFramedBatchProgress(params.progress, progressBeforeBatch); + } + if (params.progress.hasDelivered) { + throw markTelegramDeliveryErrorVisible(error); + } + throw error; + } } function isVoiceMessagesForbidden(err: unknown): boolean { @@ -263,6 +388,8 @@ async function sendTelegramVoiceFallbackText(opts: { text: string; chunkText: (markdown: string) => TelegramTextChunk[]; replyToId?: number; + replyToMode: ReplyToMode; + progress: DeliveryProgress; replyQuoteMessageId?: number; replyQuotePosition?: number; replyQuoteEntities?: unknown[]; @@ -274,29 +401,59 @@ async function sendTelegramVoiceFallbackText(opts: { }): Promise { let firstDeliveredMessageId: number | undefined; const chunks = filterEmptyTelegramTextChunks(opts.chunkText(opts.text)); - let appliedReplyTo = false; - for (const chunk of chunks) { - // Only apply reply reference, quote text, and buttons to the first chunk. - const replyToForChunk = !appliedReplyTo ? opts.replyToId : undefined; - const applyQuoteForChunk = !appliedReplyTo; - const messageId = await sendTelegramText(opts.bot, opts.chatId, chunk.text, opts.runtime, { - replyToMessageId: replyToForChunk, - replyQuoteMessageId: applyQuoteForChunk ? opts.replyQuoteMessageId : undefined, - replyQuoteText: applyQuoteForChunk ? opts.replyQuoteText : undefined, - replyQuotePosition: applyQuoteForChunk ? opts.replyQuotePosition : undefined, - replyQuoteEntities: applyQuoteForChunk ? opts.replyQuoteEntities : undefined, - thread: opts.thread, - textMode: "markdown", - linkPreview: opts.linkPreview, - silent: opts.silent, - replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined, + const progressBeforeBatch = { ...opts.progress }; + try { + await sendChunkedTelegramReplyText({ + chunks, + progress: opts.progress, + replyToId: opts.replyToId, + replyToMode: resolveTelegramTextChunkReplyToMode(chunks, opts.replyToMode), + replyMarkup: opts.replyMarkup, + replyQuoteText: opts.replyQuoteText, + quoteOnlyOnFirstChunk: true, + // Track visible chunks immediately; the caller increments the logical reply + // count once after the complete fallback succeeds. + markDelivered: (progress) => { + progress.hasDelivered = true; + }, + sendChunk: async ({ chunk, isFirstChunk, replyToMessageId, replyMarkup, replyQuoteText }) => { + const messageId = await sendTelegramText(opts.bot, opts.chatId, chunk.text, opts.runtime, { + replyToMessageId, + replyQuoteMessageId: isFirstChunk ? opts.replyQuoteMessageId : undefined, + replyQuoteText, + replyQuotePosition: isFirstChunk ? opts.replyQuotePosition : undefined, + replyQuoteEntities: isFirstChunk ? opts.replyQuoteEntities : undefined, + thread: opts.thread, + textMode: chunk.textMode ?? "markdown", + ...(chunk.plainText !== undefined + ? { standardMessage: { plainText: chunk.plainText } } + : {}), + linkPreview: opts.linkPreview, + silent: opts.silent, + replyMarkup, + }); + firstDeliveredMessageId ??= messageId; + }, }); - if (firstDeliveredMessageId == null) { - firstDeliveredMessageId = messageId; + } catch (error) { + if (isFramedStandardTextBatch(chunks)) { + if (madeFramedBatchProgress(opts.progress, progressBeforeBatch)) { + await retireIncompleteFramedBatch({ + bot: opts.bot, + chatId: opts.chatId, + runtime: opts.runtime, + thread: opts.thread, + replyToMessageId: opts.replyToId, + chunks, + silent: opts.silent, + }); + } + restoreIncompleteFramedBatchProgress(opts.progress, progressBeforeBatch); } - if (replyToForChunk) { - appliedReplyTo = true; + if (opts.progress.hasDelivered) { + throw markTelegramDeliveryErrorVisible(error); } + throw error; } return firstDeliveredMessageId; } @@ -466,6 +623,8 @@ async function deliverMediaReply(params: { text: fallbackText, chunkText: params.chunkText, replyToId: voiceFallbackReplyTo, + replyToMode: params.replyToMode, + progress: params.progress, replyQuoteMessageId: params.replyQuoteMessageId, replyQuotePosition: params.replyQuotePosition, replyQuoteEntities: params.replyQuoteEntities, @@ -491,6 +650,7 @@ async function deliverMediaReply(params: { delete noCaptionParams.caption; delete noCaptionParams.parse_mode; await sendVoiceMedia(noCaptionParams); + markReplyApplied(params.progress, replyToMessageId); const fallbackText = resolveVoiceFallbackText(params.reply); if (fallbackText?.trim()) { await sendTelegramVoiceFallbackText({ @@ -499,7 +659,9 @@ async function deliverMediaReply(params: { runtime: params.runtime, text: fallbackText, chunkText: params.chunkText, - replyToId: undefined, + replyToId: params.replyToId, + replyToMode: params.replyToMode, + progress: params.progress, thread: params.thread, linkPreview: params.linkPreview, silent: params.silent, @@ -682,6 +844,10 @@ export async function deliverReplies(params: { thread?: TelegramThreadSpec | null; tableMode?: MarkdownTableMode; chunkMode?: ChunkMode; + /** Standard Bot API messages remain visible to bot-originated QA/automation turns. */ + standardMessages?: boolean; + /** Reply target synthesized for bot-originated terminal/native responses. */ + defaultReplyToId?: string; /** Callback invoked before sending a voice message to switch typing indicator. */ onVoiceRecording?: () => Promise | void; /** Controls whether link previews are shown. Default: true (previews enabled). */ @@ -717,6 +883,7 @@ export async function deliverReplies(params: { textLimit: Math.min(params.textLimit, TELEGRAM_RICH_TEXT_LIMIT), chunkMode: params.chunkMode ?? "length", tableMode: params.tableMode, + standardMessages: params.standardMessages, }); const candidateReplies: ReplyPayload[] = []; for (const reply of params.replies) { @@ -769,8 +936,18 @@ export async function deliverReplies(params: { ? reply.spokenText : undefined; const hookContent = spokenHookContent ?? rawContent; + // Parsed reply directives predate provenance stamps; their explicit tag + // fields remain authoritative for direct native-command delivery. + const hasExplicitReplyTarget = + reply.replyToId != null && + (reply.replyToIdSource !== "implicit" || + reply.replyToTag === true || + reply.replyToCurrent === true); const replyToId = - params.replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId); + hasExplicitReplyTarget || params.replyToMode !== "off" + ? resolveTelegramReplyId(reply.replyToId ?? params.defaultReplyToId) + : undefined; + const effectiveReplyToMode: ReplyToMode = hasExplicitReplyTarget ? "all" : params.replyToMode; const replyQuote = resolveReplyQuoteForSend({ replyToId, replyQuoteByMessageId: params.replyQuoteByMessageId, @@ -838,7 +1015,7 @@ export async function deliverReplies(params: { linkPreview: params.linkPreview, silent: params.silent, replyToId, - replyToMode: params.replyToMode, + replyToMode: effectiveReplyToMode, progress, }); } else { @@ -863,7 +1040,7 @@ export async function deliverReplies(params: { replyQuoteEntities: replyQuote.entities, replyMarkup, replyToId, - replyToMode: params.replyToMode, + replyToMode: effectiveReplyToMode, progress, }); firstDeliveredMessageId = mediaDelivery.firstDeliveredMessageId; @@ -908,7 +1085,7 @@ export async function deliverReplies(params: { isGroup: params.mirrorIsGroup, groupId: params.mirrorGroupId, }); - throw error; + throw progress.hasDelivered ? markTelegramDeliveryErrorVisible(error) : error; } } diff --git a/extensions/telegram/src/bot/delivery.send.ts b/extensions/telegram/src/bot/delivery.send.ts index 1741334b6b65..074fdbc8d895 100644 --- a/extensions/telegram/src/bot/delivery.send.ts +++ b/extensions/telegram/src/bot/delivery.send.ts @@ -22,6 +22,7 @@ import type { TelegramThreadSpec } from "./helpers.js"; export { buildTelegramSendParams } from "../reply-parameters.js"; const QUOTE_PARAM_RE = /\bquote not found\b|\bQUOTE_TEXT_INVALID\b|\bquote text invalid\b/i; +const HTML_PARSE_ERROR_RE = /can't parse entities|parse entities|find end of the entity/i; const GrammyErrorCtor: typeof GrammyError | undefined = typeof GrammyError === "function" ? GrammyError : undefined; @@ -99,6 +100,7 @@ export async function sendTelegramText( replyQuoteEntities?: unknown[]; thread?: TelegramThreadSpec | null; textMode?: "markdown" | "html"; + standardMessage?: { plainText: string }; linkPreview?: boolean; silent?: boolean; replyMarkup?: ReturnType; @@ -115,6 +117,37 @@ export async function sendTelegramText( }); const richParams = toTelegramRichMessageContextParams(baseParams); const textMode = opts?.textMode ?? "markdown"; + if (opts?.standardMessage) { + const sendStandard = async (message: string, parseMode?: "HTML") => { + const res = await sendTelegramWithThreadFallback({ + operation: "sendMessage", + runtime, + thread: opts.thread, + requestParams: baseParams, + send: (effectiveParams) => + bot.api.sendMessage(chatId, message, { + ...(parseMode ? { parse_mode: parseMode } : {}), + ...(opts.linkPreview === false ? { link_preview_options: { is_disabled: true } } : {}), + ...(opts.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), + ...effectiveParams, + }), + }); + runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id}`); + return res.message_id; + }; + if (text === opts.standardMessage.plainText) { + return await sendStandard(text); + } + try { + return await sendStandard(text, "HTML"); + } catch (err) { + if (!HTML_PARSE_ERROR_RE.test(formatErrorMessage(err))) { + throw err; + } + runtime.log?.("telegram formatted send failed; retrying without formatting"); + return await sendStandard(opts.standardMessage.plainText); + } + } const richMessage = buildTelegramRichMessage(text, textMode, { skipEntityDetection: opts?.linkPreview === false, }); diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index f95066d8c45d..75465e883c87 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -2,6 +2,11 @@ import type { Bot } from "grammy"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + resolveTelegramStandardFragmentFrame, + stripTelegramStandardFragmentMarker, + TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS, +} from "../standard-text.js"; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -813,6 +818,58 @@ describe("deliverReplies", () => { }); }); + it("marks later media failures as partial after visible delivery", async () => { + const runtime = createRuntime(); + const sendPhoto = vi.fn().mockResolvedValue({ message_id: 13, chat: { id: "123" } }); + const bot = createBot({ sendPhoto }); + loadWebMedia + .mockResolvedValueOnce({ + buffer: Buffer.from("first"), + contentType: "image/jpeg", + fileName: "first.jpg", + }) + .mockRejectedValueOnce(new Error("second media load failed")); + + await expect( + deliverWith({ + replies: [ + { + mediaUrls: ["https://example.com/first.jpg", "https://example.com/second.jpg"], + }, + ], + runtime, + bot, + }), + ).rejects.toMatchObject({ sentBeforeError: true, visibleReplySent: true }); + + expect(sendPhoto).toHaveBeenCalledTimes(1); + }); + + it("keeps peer reply targeting on standard media follow-up text", async () => { + const runtime = createRuntime(); + const sendPhoto = vi.fn().mockResolvedValue({ message_id: 13, chat: { id: "123" } }); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 14, chat: { id: "123" } }); + const bot = createBot({ sendPhoto, sendMessage }); + mockMediaLoad("photo.jpg", "image/jpeg", "image"); + + await deliverWith({ + replies: [ + { + mediaUrl: "https://example.com/photo.jpg", + text: "A".repeat(1_100), + replyToId: "700", + }, + ], + runtime, + bot, + standardMessages: true, + replyToMode: "first", + }); + + expectRecordFields(mockCallArg(sendPhoto, 0, 2), { reply_to_message_id: 700 }); + expectRecordFields(mockCallArg(sendMessage, 0, 2), { reply_to_message_id: 700 }); + }); + it("skips rich entity detection when link previews are disabled", async () => { const runtime = createRuntime(); const sendMessage = vi.fn().mockResolvedValue({ @@ -833,6 +890,230 @@ describe("deliverReplies", () => { expectRecordFields(mockCallArg(sendMessage, 0, 2), { skip_entity_detection: true }); }); + it("uses standard messages for bot-originated reply delivery", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 3, chat: { id: "123" } }); + const sendRichMessage = vi.fn(); + const bot = { api: { sendMessage, raw: { sendRichMessage } } } as unknown as Bot; + + await deliverWith({ + replies: [{ text: "**Ready**" }], + runtime, + bot, + standardMessages: true, + }); + + expect(sendMessage).toHaveBeenCalledWith( + "123", + "Ready", + expect.objectContaining({ parse_mode: "HTML" }), + ); + expect(sendRichMessage).not.toHaveBeenCalled(); + }); + + it("chunks standard bot-originated replies at the sendMessage limit", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 3, chat: { id: "123" } }); + const bot = { api: { sendMessage } } as unknown as Bot; + + await deliverWith({ + replies: [{ text: "A".repeat(40_000), replyToId: "700" }], + runtime, + bot, + standardMessages: true, + textLimit: 100_000, + replyToMode: "all", + }); + + expect(sendMessage.mock.calls).toHaveLength(10); + expect(sendMessage.mock.calls.every((call) => String(call[1]).length <= 4096)).toBe(true); + expect(sendMessage.mock.calls.every((call) => String(call[1]).startsWith("\u2060"))).toBe(true); + expect( + sendMessage.mock.calls.slice(0, -1).every((call) => String(call[1]).length >= 4000), + ).toBe(true); + expect( + sendMessage.mock.calls.every( + (call) => (call[2] as { reply_to_message_id?: number }).reply_to_message_id === 700, + ), + ).toBe(true); + }); + + it("rejects standard replies that exceed the framed receiver limit before sending", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn(); + const bot = { api: { sendMessage } } as unknown as Bot; + const oversizedText = "A".repeat(4_094 * TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS + 1); + + await expect( + deliverWith({ + replies: [{ text: oversizedText }], + runtime, + bot, + standardMessages: true, + textLimit: oversizedText.length, + }), + ).rejects.toThrow(/fragment safety limit/); + + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("keeps single-use peer reply targets on every standard transport fragment", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 3, chat: { id: "123" } }); + const bot = { api: { sendMessage } } as unknown as Bot; + + await deliverWith({ + replies: [{ text: "A".repeat(5_000), replyToId: "700" }], + runtime, + bot, + standardMessages: true, + textLimit: 100_000, + replyToMode: "first", + }); + + expect(sendMessage.mock.calls).toHaveLength(2); + expect( + sendMessage.mock.calls.every( + (call) => (call[2] as { reply_to_message_id?: number }).reply_to_message_id === 700, + ), + ).toBe(true); + }); + + it.each([ + "/status continuation", + "stop", + `${" ".repeat(96)}/reset`, + `${" ".repeat(96)}stop`, + `/${"B".repeat(4_095)}`, + ])("keeps %s from starting a standard-message continuation chunk", async (continuation) => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 3, chat: { id: "123" } }); + const bot = { api: { sendMessage } } as unknown as Bot; + const text = `${"A".repeat(4_094)}${continuation}`; + + await deliverWith({ + replies: [{ text }], + runtime, + bot, + standardMessages: true, + textLimit: 100_000, + }); + + const sentChunks = sendMessage.mock.calls.map((call) => String(call[1])); + expect(sentChunks.length).toBeGreaterThanOrEqual(2); + expect(sentChunks.every((chunk) => chunk.length <= 4_096)).toBe(true); + expect(sentChunks.every((chunk) => chunk.startsWith("\u2060"))).toBe(true); + expect(sentChunks.map(stripTelegramStandardFragmentMarker).join("")).toBe(text); + expect(sentChunks[0]?.length).toBeGreaterThanOrEqual(4_000); + expect(sentChunks[1]?.trimStart()).not.toMatch(/^\//); + expect(sentChunks[1]?.trimStart().toLowerCase()).not.toBe("stop"); + }); + + it("keeps incomplete standard-message batches eligible for fallback", async () => { + const runtime = createRuntime(); + const failure = new Error("second chunk failed"); + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ message_id: 3, chat: { id: "123" } }) + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce({ message_id: 4, chat: { id: "123" } }); + const bot = { api: { sendMessage } } as unknown as Bot; + + await expect( + deliverWith({ + replies: [{ text: "A".repeat(5_000) }], + runtime, + bot, + standardMessages: true, + textLimit: 100_000, + }), + ).rejects.toBe(failure); + + expect(sendMessage).toHaveBeenCalledTimes(3); + const startFrame = resolveTelegramStandardFragmentFrame(String(sendMessage.mock.calls[0]?.[1])); + expect(resolveTelegramStandardFragmentFrame(String(sendMessage.mock.calls[2]?.[1]))).toEqual({ + batchId: startFrame?.batchId, + kind: "abort", + }); + expect(failure).not.toHaveProperty("sentBeforeError"); + expect(failure).not.toHaveProperty("visibleReplySent"); + }); + + it("keeps an incomplete standard follow-up partial after visible media", async () => { + const runtime = createRuntime(); + const sendPhoto = vi.fn().mockResolvedValue({ message_id: 13, chat: { id: "123" } }); + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ message_id: 14, chat: { id: "123" } }) + .mockRejectedValueOnce(new Error("second follow-up fragment failed")) + .mockResolvedValueOnce({ message_id: 15, chat: { id: "123" } }); + const bot = createBot({ sendPhoto, sendMessage }); + mockMediaLoad("photo.jpg", "image/jpeg", "image"); + + await expect( + deliverWith({ + replies: [ + { + mediaUrl: "https://example.com/photo.jpg", + text: "A".repeat(6_000), + }, + ], + runtime, + bot, + standardMessages: true, + textLimit: 100_000, + }), + ).rejects.toMatchObject({ + message: "second follow-up fragment failed", + sentBeforeError: true, + visibleReplySent: true, + }); + + expect(sendPhoto).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledTimes(3); + expect(resolveTelegramStandardFragmentFrame(String(sendMessage.mock.calls[2]?.[1]))?.kind).toBe( + "abort", + ); + }); + + it("chunks long parser-empty standard-message fallbacks", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 3, chat: { id: "123" } }); + const bot = { api: { sendMessage } } as unknown as Bot; + + await deliverWith({ + replies: [{ text: Array.from({ length: 5_000 }, () => ">").join("\n") }], + runtime, + bot, + standardMessages: true, + textLimit: 100_000, + }); + + expect(sendMessage.mock.calls.length).toBeGreaterThan(1); + expect(sendMessage.mock.calls.every((call) => String(call[1]).length <= 4096)).toBe(true); + }); + + it("retries standard bot-originated replies as plain text after HTML parse errors", async () => { + const runtime = createRuntime(); + const sendMessage = vi + .fn() + .mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities")) + .mockResolvedValueOnce({ message_id: 3, chat: { id: "123" } }); + const bot = { api: { sendMessage } } as unknown as Bot; + + await deliverWith({ + replies: [{ text: "[docs](https://example.com)" }], + runtime, + bot, + standardMessages: true, + }); + + expect(firstMockCallArg(sendMessage, 1)).toBe('docs'); + expect(mockCallArg(sendMessage, 0, 2)).toHaveProperty("parse_mode", "HTML"); + expect(mockCallArg(sendMessage, 1, 1)).toBe("docs (https://example.com)"); + expect(mockCallArg(sendMessage, 1, 2)).not.toHaveProperty("parse_mode"); + }); + it("includes message_thread_id for DM topics", async () => { const { runtime, sendMessage, bot } = createSendMessageHarness(); @@ -1132,7 +1413,13 @@ describe("deliverReplies", () => { const bot = createBot({ sendMessage }); await deliverWith({ - replies: [{ text: "Hello there", replyToId: "500" }], + replies: [ + { + text: "Hello there", + replyToId: "500", + replyToIdSource: "implicit", + }, + ], runtime, bot, replyToMode: "off", @@ -1332,6 +1619,7 @@ describe("deliverReplies", () => { mediaUrl: "https://example.com/note.ogg", text: "chunk-one\n\nchunk-two", replyToId: "77", + replyToIdSource: "implicit", audioAsVoice: true, channelData: { telegram: { @@ -1365,6 +1653,80 @@ describe("deliverReplies", () => { expect(mockCallArg(sendMessage, 1, 2)).not.toHaveProperty("reply_markup"); }); + it("keeps peer reply targeting on every standard voice-fallback fragment", async () => { + const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({ + voiceError: createVoiceMessagesForbiddenError(), + sendMessageResult: { message_id: 6, chat: { id: "123" } }, + }); + mockMediaLoad("note.ogg", "audio/ogg", "voice"); + + await deliverWith({ + replies: [ + { + mediaUrl: "https://example.com/note.ogg", + text: "A".repeat(5_000), + replyToId: "77", + audioAsVoice: true, + }, + ], + runtime, + bot, + replyToMode: "first", + standardMessages: true, + textLimit: 100_000, + }); + + expect(sendVoice).toHaveBeenCalledTimes(1); + expect(sendMessage.mock.calls).toHaveLength(2); + expect( + sendMessage.mock.calls.every( + (call) => (call[2] as { reply_to_message_id?: number }).reply_to_message_id === 77, + ), + ).toBe(true); + }); + + it("keeps incomplete standard voice fallbacks eligible for fallback", async () => { + const runtime = createRuntime(); + const sendVoice = vi.fn().mockRejectedValue(createVoiceMessagesForbiddenError()); + const failure = new Error("second fallback chunk failed"); + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ message_id: 6, chat: { id: "123" } }) + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce({ message_id: 7, chat: { id: "123" } }); + const bot = createBot({ sendVoice, sendMessage }); + mockMediaLoad("note.ogg", "audio/ogg", "voice"); + + await expect( + deliverWith({ + replies: [ + { + mediaUrl: "https://example.com/note.ogg", + text: "A".repeat(5_000), + replyToId: "77", + audioAsVoice: true, + }, + ], + runtime, + bot, + replyToMode: "first", + standardMessages: true, + textLimit: 100_000, + }), + ).rejects.toBe(failure); + + expect(sendMessage).toHaveBeenCalledTimes(3); + expect(resolveTelegramStandardFragmentFrame(String(sendMessage.mock.calls[2]?.[1]))?.kind).toBe( + "abort", + ); + expectRecordFields(mockCallArg(sendMessage, 2, 2), { + reply_to_message_id: 77, + allow_sending_without_reply: true, + }); + expect(failure).not.toHaveProperty("sentBeforeError"); + expect(failure).not.toHaveProperty("visibleReplySent"); + }); + it("rethrows non-VOICE_MESSAGES_FORBIDDEN errors from sendVoice", async () => { const runtime = createRuntime(); const sendVoice = vi.fn().mockRejectedValue(new Error("Network error")); @@ -1396,7 +1758,13 @@ describe("deliverReplies", () => { // Use a small textLimit to force multiple chunks await deliverReplies({ - replies: [{ text: "chunk-one\n\nchunk-two", replyToId: "700" }], + replies: [ + { + text: "chunk-one\n\nchunk-two", + replyToId: "700", + replyToIdSource: "implicit", + }, + ], chatId: "123", token: "tok", runtime, @@ -1415,6 +1783,63 @@ describe("deliverReplies", () => { expect(mockCallArg(sendMessage, 1, 2)).not.toHaveProperty("reply_to_message_id"); }); + it("preserves explicit reply targets across chunks when reply mode is off", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 20, + chat: { id: "123" }, + }); + const bot = createBot({ sendMessage }); + + await deliverReplies({ + replies: [ + { + text: "chunk-one\n\nchunk-two", + replyToId: "701", + replyToTag: true, + }, + ], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 12, + }); + + expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2); + for (const call of sendMessage.mock.calls) { + expectRecordFields(call[2], { + reply_to_message_id: 701, + allow_sending_without_reply: true, + }); + } + }); + + it("treats an unstamped payload reply target as explicit when reply mode is off", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 20, + chat: { id: "123" }, + }); + const bot = createBot({ sendMessage }); + + await deliverReplies({ + replies: [{ text: "plugin reply", replyToId: "702" }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4096, + }); + + expectRecordFields(mockCallArg(sendMessage, 0, 2), { + reply_to_message_id: 702, + allow_sending_without_reply: true, + }); + }); + it("clamps reply chunks to Telegram rich message limit", async () => { const runtime = createRuntime(); const sendMessage = vi.fn().mockResolvedValue({ @@ -1477,7 +1902,13 @@ describe("deliverReplies", () => { mockMediaLoad("b.jpg", "image/jpeg", "img2"); await deliverReplies({ - replies: [{ mediaUrls: ["https://a.jpg", "https://b.jpg"], replyToId: "900" }], + replies: [ + { + mediaUrls: ["https://a.jpg", "https://b.jpg"], + replyToId: "900", + replyToIdSource: "implicit", + }, + ], chatId: "123", token: "tok", runtime, diff --git a/extensions/telegram/src/deferred-admission.test.ts b/extensions/telegram/src/deferred-admission.test.ts new file mode 100644 index 000000000000..438c5cca8ade --- /dev/null +++ b/extensions/telegram/src/deferred-admission.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; +import { + combineTelegramDeferredAdmissionCallbacks, + settleTelegramDeferredAdmissionCallbacks, +} from "./deferred-admission.js"; + +describe("combineTelegramDeferredAdmissionCallbacks", () => { + it("admits one combined turn and finalizes remaining source messages", async () => { + const first = vi.fn(async () => false); + const primary = vi.fn(async () => false); + const combined = combineTelegramDeferredAdmissionCallbacks([first, primary], primary); + + await expect(combined?.(true)).resolves.toBe(false); + expect(primary).toHaveBeenCalledWith(true); + expect(first).toHaveBeenCalledWith(false); + }); + + it("finalizes other source messages when the combined turn is suppressed", async () => { + const first = vi.fn(async () => false); + const primary = vi.fn(async () => true); + const combined = combineTelegramDeferredAdmissionCallbacks([first, primary], primary); + + await expect(combined?.(true)).resolves.toBe(true); + expect(first).toHaveBeenCalledWith(false, false); + }); + + it("finalizes every source message when mention admission rejects the combined turn", async () => { + const first = vi.fn(async () => false); + const second = vi.fn(async () => false); + const combined = combineTelegramDeferredAdmissionCallbacks([first, second]); + + await expect(combined?.(false)).resolves.toBe(false); + expect(first).toHaveBeenCalledWith(false); + expect(second).toHaveBeenCalledWith(false); + }); + + it("cannot suppress siblings that already passed admission", async () => { + const deferred = vi.fn(async () => true); + const combined = combineTelegramDeferredAdmissionCallbacks([deferred], deferred, false); + + await expect(combined?.(true)).resolves.toBe(false); + expect(deferred).toHaveBeenCalledWith(false); + }); +}); + +describe("settleTelegramDeferredAdmissionCallbacks", () => { + it("settles every callback without retrying rejected cache work", async () => { + const failure = new Error("cache unavailable"); + const failing = vi.fn(async () => { + throw failure; + }); + const succeeding = vi.fn(async () => false); + + await expect( + settleTelegramDeferredAdmissionCallbacks({ + callbacks: [failing, succeeding], + admitted: false, + cacheMessage: false, + }), + ).resolves.toEqual([failure]); + expect(failing).toHaveBeenCalledWith(false, false); + expect(succeeding).toHaveBeenCalledWith(false, false); + }); +}); diff --git a/extensions/telegram/src/deferred-admission.ts b/extensions/telegram/src/deferred-admission.ts new file mode 100644 index 000000000000..1b8109a0ae56 --- /dev/null +++ b/extensions/telegram/src/deferred-admission.ts @@ -0,0 +1,54 @@ +export type TelegramDeferredAdmissionCallback = ( + admitted: boolean, + cacheMessage?: boolean, +) => Promise; + +export async function settleTelegramDeferredAdmissionCallbacks(params: { + callbacks: TelegramDeferredAdmissionCallback[]; + admitted: boolean; + cacheMessage: boolean; +}): Promise { + const results = await Promise.allSettled( + params.callbacks.map((callback) => + params.cacheMessage ? callback(params.admitted) : callback(params.admitted, false), + ), + ); + return results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); +} + +export function combineTelegramDeferredAdmissionCallbacks( + callbacks: TelegramDeferredAdmissionCallback[], + primaryCallback = callbacks.at(-1), + canSuppressCombinedTurn = true, +): TelegramDeferredAdmissionCallback | undefined { + if (callbacks.length === 0) { + return undefined; + } + const invokeCallback = ( + callback: TelegramDeferredAdmissionCallback, + admitted: boolean, + cacheMessage: boolean, + ) => (cacheMessage ? callback(admitted) : callback(admitted, false)); + return async (admitted: boolean, cacheMessage = true) => { + if (admitted && canSuppressCombinedTurn) { + if (primaryCallback && (await invokeCallback(primaryCallback, true, cacheMessage))) { + // Suppressed combined turns release sibling reservations without leaking + // their source messages into later prompt context. + await Promise.all( + callbacks + .filter((callback) => callback !== primaryCallback) + .map((callback) => callback(false, false)), + ); + return true; + } + await Promise.all( + callbacks + .filter((callback) => callback !== primaryCallback) + .map((callback) => invokeCallback(callback, false, cacheMessage)), + ); + return false; + } + await Promise.all(callbacks.map((callback) => invokeCallback(callback, false, cacheMessage))); + return false; + }; +} diff --git a/extensions/telegram/src/delivery-error.ts b/extensions/telegram/src/delivery-error.ts new file mode 100644 index 000000000000..26e849353bcf --- /dev/null +++ b/extensions/telegram/src/delivery-error.ts @@ -0,0 +1,19 @@ +// Telegram plugin module implements partial delivery error metadata. +export function markTelegramDeliveryErrorVisible(error: unknown): unknown { + if (typeof error === "object" && error !== null && Object.isExtensible(error)) { + Object.assign(error, { sentBeforeError: true, visibleReplySent: true }); + return error; + } + const visibleError = new Error("visible Telegram delivery failed", { cause: error }); + Object.assign(visibleError, { sentBeforeError: true, visibleReplySent: true }); + return visibleError; +} + +export function isTelegramDeliveryErrorVisible(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + (("sentBeforeError" in error && error.sentBeforeError === true) || + ("visibleReplySent" in error && error.visibleReplySent === true)) + ); +} diff --git a/extensions/telegram/src/outbound-adapter.test.ts b/extensions/telegram/src/outbound-adapter.test.ts index 15090c3aaacd..894ffa9a886d 100644 --- a/extensions/telegram/src/outbound-adapter.test.ts +++ b/extensions/telegram/src/outbound-adapter.test.ts @@ -135,6 +135,55 @@ describe("telegramOutbound", () => { expect(result).toEqual({ channel: "telegram", messageId: "tg-2", chatId: "12345" }); }); + it("marks later payload media failures as partial delivery", async () => { + const error = new Error("second media send failed"); + sendMessageTelegramMock + .mockResolvedValueOnce({ messageId: "tg-1", chatId: "12345" }) + .mockRejectedValueOnce(error); + + await expect( + telegramOutbound.sendPayload!({ + cfg: {} as never, + to: "12345", + text: "", + payload: { + text: "Album", + mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], + }, + deps: { sendTelegram: sendMessageTelegramMock }, + }), + ).rejects.toMatchObject({ sentBeforeError: true, visibleReplySent: true }); + + expect(sendMessageTelegramMock).toHaveBeenCalledTimes(2); + }); + + it("consumes implicit single-use replies across standard payload media", async () => { + sendMessageTelegramMock + .mockResolvedValueOnce({ messageId: "tg-1", chatId: "12345" }) + .mockResolvedValueOnce({ messageId: "tg-2", chatId: "12345" }); + + await telegramOutbound.sendPayload!({ + cfg: {} as never, + to: "12345", + text: "", + payload: { + text: "Peer reply", + mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], + channelData: { telegram: { standardMessage: true } }, + }, + replyToId: "900", + replyToIdSource: "implicit", + replyToMode: "first", + deps: { sendTelegram: sendMessageTelegramMock }, + }); + + expect(callOptionsAt(sendMessageTelegramMock, 0, "12345", "Peer reply")).toMatchObject({ + replyToMessageId: 900, + replyToMode: "first", + }); + expect(callOptionsAt(sendMessageTelegramMock, 1, "12345", "").replyToMessageId).toBeUndefined(); + }); + it("uses interactive button labels as fallback text for button-only payloads", async () => { sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-buttons", chatId: "12345" }); diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 9b94e184505d..ba879d4fa7f1 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -17,9 +17,11 @@ import { resolvePayloadMediaUrls, sendPayloadMediaSequenceOrFallback, } from "openclaw/plugin-sdk/reply-payload"; +import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { TelegramInlineButtons } from "./button-types.js"; import { resolveTelegramInlineButtons } from "./button-types.js"; +import { markTelegramDeliveryErrorVisible } from "./delivery-error.js"; import { resolveTelegramInteractiveTextFallback } from "./interactive-fallback.js"; import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js"; import { splitTelegramRichTextChunks, TELEGRAM_RICH_TEXT_LIMIT } from "./rich-message.js"; @@ -66,6 +68,8 @@ async function resolveTelegramSendContext(params: { deps?: OutboundSendDeps; accountId?: string | null; replyToId?: string | null; + replyToMode?: TelegramSendOpts["replyToMode"]; + replyToIdSource?: TelegramSendOpts["replyToIdSource"]; threadId?: string | number | null; formatting?: OutboundDeliveryFormattingOptions; silent?: boolean; @@ -79,6 +83,8 @@ async function resolveTelegramSendContext(params: { textMode?: "html"; messageThreadId?: number; replyToMessageId?: number; + replyToMode?: TelegramSendOpts["replyToMode"]; + replyToIdSource?: TelegramSendOpts["replyToIdSource"]; accountId?: string; silent?: boolean; gatewayClientScopes?: readonly string[]; @@ -92,6 +98,8 @@ async function resolveTelegramSendContext(params: { cfg: params.cfg, messageThreadId: parseTelegramThreadId(params.threadId), replyToMessageId: parseTelegramReplyToMessageId(params.replyToId), + ...(params.replyToMode ? { replyToMode: params.replyToMode } : {}), + ...(params.replyToIdSource ? { replyToIdSource: params.replyToIdSource } : {}), accountId: params.accountId ?? undefined, silent: params.silent, gatewayClientScopes: params.gatewayClientScopes, @@ -125,7 +133,7 @@ export async function sendTelegramPayloadMessages(params: { baseOpts: Omit, "buttons" | "mediaUrl" | "quoteText">; }): Promise>> { const telegramData = params.payload.channelData?.telegram as - | { buttons?: TelegramInlineButtons; quoteText?: string } + | { buttons?: TelegramInlineButtons; quoteText?: string; standardMessage?: boolean } | undefined; const quoteText = typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; @@ -145,8 +153,37 @@ export async function sendTelegramPayloadMessages(params: { const payloadOpts = { ...params.baseOpts, quoteText, + standardMessage: telegramData?.standardMessage === true, ...(params.payload.audioAsVoice === true ? { asVoice: true } : {}), }; + const singleUseImplicitReply = + payloadOpts.standardMessage && + payloadOpts.replyToMessageId != null && + payloadOpts.replyToIdSource !== "explicit" && + payloadOpts.replyToMode != null && + isSingleUseReplyToMode(payloadOpts.replyToMode); + let implicitReplyAvailable = true; + let deliveredSendCount = 0; + const sendWithReplyFanout = async (textLocal: string, options: TelegramSendOpts) => { + const effectiveOptions = + singleUseImplicitReply && !implicitReplyAvailable + ? { ...options, replyToMessageId: undefined } + : options; + let result: Awaited>; + try { + result = await params.send(params.to, textLocal, effectiveOptions); + } catch (error) { + if (deliveredSendCount > 0) { + throw markTelegramDeliveryErrorVisible(error); + } + throw error; + } + deliveredSendCount += 1; + if (singleUseImplicitReply && effectiveOptions.replyToMessageId != null) { + implicitReplyAvailable = false; + } + return result; + }; // Telegram allows reply_markup on media; attach buttons only to the first send. return await sendPayloadMediaSequenceOrFallback({ @@ -154,12 +191,12 @@ export async function sendTelegramPayloadMessages(params: { mediaUrls, fallbackResult: { messageId: "unknown", chatId: params.to }, sendNoMedia: async () => - await params.send(params.to, text, { + await sendWithReplyFanout(text, { ...payloadOpts, buttons, }), send: async ({ text: textLocal, mediaUrl, isFirst }) => - await params.send(params.to, textLocal, { + await sendWithReplyFanout(textLocal, { ...payloadOpts, mediaUrl, ...(isFirst ? { buttons } : {}), diff --git a/extensions/telegram/src/peer-bot-admission.test.ts b/extensions/telegram/src/peer-bot-admission.test.ts new file mode 100644 index 000000000000..74e1229097e3 --- /dev/null +++ b/extensions/telegram/src/peer-bot-admission.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTelegramPeerBotAdmissionCoordinator } from "./peer-bot-admission.js"; + +describe("createTelegramPeerBotAdmissionCoordinator", () => { + it("forwards no-cache cleanup through a reservation", async () => { + const coordinator = createTelegramPeerBotAdmissionCoordinator(); + const check = vi.fn(async () => false); + const admission = coordinator.reserve("peer", check); + + await expect(admission(false, false)).resolves.toBe(false); + expect(check).toHaveBeenCalledWith(false, false); + }); +}); diff --git a/extensions/telegram/src/peer-bot-admission.ts b/extensions/telegram/src/peer-bot-admission.ts new file mode 100644 index 000000000000..3fc0c561d83c --- /dev/null +++ b/extensions/telegram/src/peer-bot-admission.ts @@ -0,0 +1,66 @@ +// Telegram plugin module serializes peer-bot loop admission across ingress paths. +import type { TelegramDeferredAdmissionCallback } from "./deferred-admission.js"; + +export type TelegramPeerBotAdmissionCoordinator = { + reserve: ( + key: string, + check: (admitted: boolean, cacheMessage?: boolean) => boolean | Promise, + ) => TelegramDeferredAdmissionCallback; + registerCancellation: (key: string, cancel: () => Promise) => () => void; + cancel: (key: string) => Promise; +}; + +export function buildTelegramPeerBotAdmissionKey(params: { + accountId: string; + chatId: number; + threadId?: number; + senderId: string; + receiverId?: number; +}): string { + return `${params.accountId}:${params.chatId}:${params.threadId ?? "main"}:${params.senderId}:${params.receiverId ?? "unknown"}`; +} + +export function createTelegramPeerBotAdmissionCoordinator(): TelegramPeerBotAdmissionCoordinator { + const tails = new Map>(); + const cancellations = new Map Promise>>(); + return { + reserve: (key, check) => { + const previous = tails.get(key) ?? Promise.resolve(); + let release!: () => void; + const completed = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.catch(() => undefined).then(() => completed); + tails.set(key, tail); + let result: Promise | undefined; + return (admitted, cacheMessage = true) => { + result ??= (async () => { + await previous.catch(() => undefined); + try { + return await check(admitted, cacheMessage); + } finally { + release(); + if (tails.get(key) === tail) { + tails.delete(key); + } + } + })(); + return result; + }; + }, + registerCancellation: (key, cancel) => { + const callbacks = cancellations.get(key) ?? new Set<() => Promise>(); + callbacks.add(cancel); + cancellations.set(key, callbacks); + return () => { + callbacks.delete(cancel); + if (callbacks.size === 0) { + cancellations.delete(key); + } + }; + }, + cancel: async (key) => { + await Promise.allSettled([...(cancellations.get(key) ?? [])].map((cancel) => cancel())); + }, + }; +} diff --git a/extensions/telegram/src/peer-bot-loop.ts b/extensions/telegram/src/peer-bot-loop.ts new file mode 100644 index 000000000000..c12fd972fe84 --- /dev/null +++ b/extensions/telegram/src/peer-bot-loop.ts @@ -0,0 +1,27 @@ +import { recordChannelBotPairLoopAndCheckSuppression } from "openclaw/plugin-sdk/channel-inbound"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { TelegramContext } from "./bot/types.js"; + +export function shouldSuppressTelegramPeerBotTurn(params: { + ctx: TelegramContext; + cfg: OpenClawConfig; + accountId: string; +}): boolean { + const msg = params.ctx.message; + if ( + msg.from?.is_bot !== true || + msg.sender_chat != null || + msg.from.id == null || + params.ctx.me?.id == null + ) { + return false; + } + return recordChannelBotPairLoopAndCheckSuppression({ + scopeId: params.accountId, + conversationId: `${msg.chat.id}:${msg.message_thread_id ?? ""}`, + senderId: String(msg.from.id), + receiverId: String(params.ctx.me.id), + defaultsConfig: params.cfg.channels?.defaults?.botLoopProtection, + defaultEnabled: true, + }).suppressed; +} diff --git a/extensions/telegram/src/peer-bot-turn.ts b/extensions/telegram/src/peer-bot-turn.ts new file mode 100644 index 000000000000..b6ebd4c2d6a2 --- /dev/null +++ b/extensions/telegram/src/peer-bot-turn.ts @@ -0,0 +1,24 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export type TelegramPeerBotTurn = { + accountId: string; + chatAliases?: string[]; + chatId: string; + messageId: number; + senderAliases?: string[]; + senderId: string; + threadId?: number; +}; + +const telegramPeerBotTurn = new AsyncLocalStorage(); + +export function runWithTelegramPeerBotTurn( + turn: TelegramPeerBotTurn, + run: () => Promise, +): Promise { + return telegramPeerBotTurn.run(turn, run); +} + +export function getTelegramPeerBotTurn(): TelegramPeerBotTurn | undefined { + return telegramPeerBotTurn.getStore(); +} diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 59b8a5535a8a..13ffac167dbe 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -32,6 +32,10 @@ import { setTelegramSentMessageStoreForTest, wasSentByBot, } from "./sent-message-cache.js"; +import { + resolveTelegramStandardFragmentFrame, + stripTelegramStandardFragmentMarker, +} from "./standard-text.js"; installTelegramSendTestHooks(); @@ -854,6 +858,56 @@ describe("sendMessageTelegram", () => { ); }); + it("records a completed standard-message batch once without transport framing", async () => { + const storePath = `/tmp/openclaw-telegram-send-chunks-${process.pid}-${Date.now()}.json`; + const cfg = { session: { store: storePath } }; + const source = "a".repeat(5_000); + let messageId = 1_600; + botApi.sendMessage.mockImplementation(async (_chatId, text) => ({ + message_id: messageId++, + date: 1_779_394_740, + chat: { id: "-1003966283270", type: "supergroup", title: "Chunk cache" }, + from: { id: 42, is_bot: true, first_name: "OpenClaw" }, + text, + })); + + await sendMessageTelegram("-1003966283270", source, { + cfg, + token: "tok", + standardMessage: true, + }); + + const cache = createTelegramMessageCache({ + scope: resolveTelegramMessageCacheScope(storePath), + }); + await cache.record({ + accountId: "default", + chatId: "-1003966283270", + msg: { + chat: { id: -1003966283270, type: "supergroup", title: "Chunk cache" }, + message_id: 1_700, + date: 1_779_425_460, + text: "next", + from: { id: 7, is_bot: false, first_name: "Ada" }, + }, + }); + const context = await buildTelegramConversationContext({ + cache, + accountId: "default", + chatId: "-1003966283270", + messageId: "1700", + replyChainNodes: [], + recentLimit: 10, + replyTargetWindowSize: 2, + }); + const sentBodies = context + .filter((entry) => Number(entry.node.messageId) >= 1_600) + .map((entry) => entry.node.body); + + expect(sentBodies).toEqual([source]); + expect(sentBodies[0]).not.toContain("\u2060"); + }); + it("normalizes raw code language HTML before sending", async () => { const chatId = "123"; const text = [ @@ -922,6 +976,179 @@ describe("sendMessageTelegram", () => { }); }); + it("uses standard text messages when requested by a peer-bot turn", async () => { + botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); + + await sendMessageTelegram("123", "**hi**", { + cfg: { channels: { telegram: { linkPreview: false } } }, + token: "tok", + standardMessage: true, + }); + + expect(botRawApi.sendRichMessage).not.toHaveBeenCalled(); + expect(botApi.sendMessage).toHaveBeenCalledWith("123", "hi", { + link_preview_options: { is_disabled: true }, + parse_mode: "HTML", + }); + }); + + it("keeps non-empty plain text when standard formatting renders empty", async () => { + botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); + + await sendMessageTelegram("123", ">", { + cfg: TELEGRAM_TEST_CFG, + token: "tok", + standardMessage: true, + }); + + expect(botApi.sendMessage).toHaveBeenCalledWith("123", ">", {}); + }); + + it("chunks long plain-text fallbacks when standard formatting renders empty", async () => { + botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); + + await sendMessageTelegram("123", Array.from({ length: 5_000 }, () => ">").join("\n"), { + cfg: TELEGRAM_TEST_CFG, + token: "tok", + standardMessage: true, + }); + + expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1); + expect(botApi.sendMessage.mock.calls.every((call) => String(call[1]).length <= 4096)).toBe( + true, + ); + }); + + it("uses visible-length chunks when standard HTML would split below fragment admission", async () => { + botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); + + await sendMessageTelegram("123", `**${"a".repeat(5_000)}**`, { + cfg: TELEGRAM_TEST_CFG, + token: "tok", + standardMessage: true, + }); + + expect(botApi.sendMessage).toHaveBeenCalledTimes(2); + expect(String(botApi.sendMessage.mock.calls[0]?.[1]).startsWith("\u2060\u200b")).toBe(true); + expect(stripTelegramStandardFragmentMarker(String(botApi.sendMessage.mock.calls[0]?.[1]))).toBe( + "a".repeat(4062), + ); + expect(botApi.sendMessage.mock.calls[0]?.[2]).not.toHaveProperty("parse_mode"); + expect(String(botApi.sendMessage.mock.calls[1]?.[1]).startsWith("\u2060\u200d")).toBe(true); + expect(stripTelegramStandardFragmentMarker(String(botApi.sendMessage.mock.calls[1]?.[1]))).toBe( + "a".repeat(938), + ); + }); + + it("keeps an incomplete standard batch eligible for fallback", async () => { + const failure = new Error("second chunk failed"); + botApi.sendMessage + .mockResolvedValueOnce({ message_id: 45, chat: { id: "123" } }) + .mockRejectedValueOnce(failure); + + await expect( + sendMessageTelegram("123", "a".repeat(5_000), { + cfg: TELEGRAM_TEST_CFG, + token: "tok", + standardMessage: true, + }), + ).rejects.toBe(failure); + expect(failure).not.toHaveProperty("sentBeforeError"); + expect(failure).not.toHaveProperty("visibleReplySent"); + const startFrame = resolveTelegramStandardFragmentFrame( + String(botApi.sendMessage.mock.calls[0]?.[1]), + ); + const abortFrame = resolveTelegramStandardFragmentFrame( + String(botApi.sendMessage.mock.calls[2]?.[1]), + ); + expect(startFrame?.kind).toBe("start"); + expect(abortFrame).toEqual({ batchId: startFrame?.batchId, kind: "abort" }); + }); + + it("does not cache an incomplete standard-message prefix", async () => { + const storePath = `/tmp/openclaw-telegram-send-incomplete-${process.pid}-${Date.now()}.json`; + const cfg = { session: { store: storePath } }; + const failure = new Error("second chunk failed"); + botApi.sendMessage + .mockResolvedValueOnce({ + message_id: 45, + date: 1_779_394_740, + chat: { id: "123", type: "private", first_name: "Peer" }, + from: { id: 42, is_bot: true, first_name: "OpenClaw" }, + }) + .mockRejectedValueOnce(failure); + + await expect( + sendMessageTelegram("123", "a".repeat(5_000), { + cfg, + token: "tok", + standardMessage: true, + }), + ).rejects.toBe(failure); + + const cache = createTelegramMessageCache({ + scope: resolveTelegramMessageCacheScope(storePath), + }); + await cache.record({ + accountId: "default", + chatId: "123", + msg: { + chat: { id: 123, type: "private", first_name: "Peer" }, + message_id: 46, + date: 1_779_425_460, + text: "next", + from: { id: 7, is_bot: false, first_name: "Ada" }, + }, + }); + const context = await buildTelegramConversationContext({ + cache, + accountId: "default", + chatId: "123", + messageId: "46", + replyChainNodes: [], + recentLimit: 10, + replyTargetWindowSize: 2, + }); + + expect(context.map((entry) => entry.node.messageId)).not.toContain("45"); + expect(context.map((entry) => entry.node.body).join("\n")).not.toContain("\u2060"); + }); + + it("preserves single-use reply targets across one standard-message payload", async () => { + botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); + + await sendMessageTelegram("123", "a".repeat(5_000), { + cfg: TELEGRAM_TEST_CFG, + token: "tok", + standardMessage: true, + replyToMessageId: 42, + replyToMode: "first", + }); + + expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1); + expect(botApi.sendMessage.mock.calls.every((call) => call[2]?.reply_to_message_id === 42)).toBe( + true, + ); + }); + + it("preserves explicit reply targets across standard-message chunks", async () => { + botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); + + await sendMessageTelegram("123", "a".repeat(5_000), { + cfg: TELEGRAM_TEST_CFG, + token: "tok", + standardMessage: true, + replyToMessageId: 42, + replyToIdSource: "explicit", + replyToMode: "first", + }); + + expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1); + expect(botApi.sendMessage.mock.calls.every((call) => call[2]?.reply_to_message_id === 42)).toBe( + true, + ); + }); + it("keeps complex markdown raw for rich message text", async () => { botApi.sendMessage.mockResolvedValue({ message_id: 46, chat: { id: "123" } }); const markdown = [ @@ -1502,6 +1729,107 @@ describe("sendMessageTelegram", () => { expect(res.messageId).toBe("71"); }); + it("marks follow-up text failures after visible media as partially delivered", async () => { + const chatId = "123"; + const sendPhoto = vi.fn().mockResolvedValue({ message_id: 70, chat: { id: chatId } }); + const sendMessage = vi.fn().mockRejectedValue(new Error("follow-up failed")); + const api = { sendPhoto, sendMessage } as unknown as { + sendPhoto: typeof sendPhoto; + sendMessage: typeof sendMessage; + }; + mockLoadedMedia({ + buffer: Buffer.from("fake-image"), + contentType: "image/jpeg", + fileName: "photo.jpg", + }); + + await expect( + sendMessageTelegram(chatId, "A".repeat(1100), { + cfg: TELEGRAM_TEST_CFG, + token: "tok", + api, + mediaUrl: "https://example.com/photo.jpg", + }), + ).rejects.toMatchObject({ + message: "follow-up failed", + sentBeforeError: true, + visibleReplySent: true, + }); + }); + + it("keeps a failed standard media follow-up partial after visible media", async () => { + const chatId = "123"; + const sendPhoto = vi.fn().mockResolvedValue({ message_id: 70, chat: { id: chatId } }); + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ message_id: 71, chat: { id: chatId } }) + .mockRejectedValueOnce(new Error("second follow-up fragment failed")) + .mockResolvedValueOnce({ message_id: 72, chat: { id: chatId } }); + const api = { sendPhoto, sendMessage } as unknown as { + sendPhoto: typeof sendPhoto; + sendMessage: typeof sendMessage; + }; + mockLoadedMedia({ + buffer: Buffer.from("fake-image"), + contentType: "image/jpeg", + fileName: "photo.jpg", + }); + + await expect( + sendMessageTelegram(chatId, "A".repeat(6_000), { + cfg: TELEGRAM_TEST_CFG, + token: "tok", + api, + mediaUrl: "https://example.com/photo.jpg", + standardMessage: true, + replyToMessageId: 42, + replyToMode: "first", + }), + ).rejects.toMatchObject({ + message: "second follow-up fragment failed", + sentBeforeError: true, + visibleReplySent: true, + }); + expect(sendPhoto).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledTimes(3); + expect(resolveTelegramStandardFragmentFrame(String(sendMessage.mock.calls[2]?.[1]))?.kind).toBe( + "abort", + ); + expect(sendMessage.mock.calls[2]?.[2]).toMatchObject({ reply_to_message_id: 42 }); + }); + + it("keeps peer reply targeting on media follow-up text", async () => { + const chatId = "123"; + const sendPhoto = vi.fn().mockResolvedValue({ message_id: 70, chat: { id: chatId } }); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 71, chat: { id: chatId } }); + const api = { sendPhoto, sendMessage } as unknown as { + sendPhoto: typeof sendPhoto; + sendMessage: typeof sendMessage; + }; + mockLoadedMedia({ + buffer: Buffer.from("fake-image"), + contentType: "image/jpeg", + fileName: "photo.jpg", + }); + + await sendMessageTelegram(chatId, "A".repeat(1_100), { + cfg: TELEGRAM_TEST_CFG, + token: "tok", + api, + mediaUrl: "https://example.com/photo.jpg", + standardMessage: true, + replyToMessageId: 42, + replyToMode: "first", + }); + + expect(firstMockCall(sendPhoto, "send photo call")[2]).toMatchObject({ + reply_to_message_id: 42, + }); + expect(firstMockCall(sendMessage, "send message call")[2]).toMatchObject({ + reply_to_message_id: 42, + }); + }); + it("chunks long default markdown media follow-up text", async () => { const chatId = "123"; const longText = `**${"A".repeat(5000)}**`; diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index 5e644eb19be9..81c53dfc9034 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -3,6 +3,7 @@ import * as grammy from "grammy"; import { type ApiClientOptions, Bot, HttpError } from "grammy"; import type { ReactionType, ReactionTypeEmoji } from "grammy/types"; import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime"; +import type { ReplyToMode } from "openclaw/plugin-sdk/config-contracts"; import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime"; import { formatUncaughtError } from "openclaw/plugin-sdk/error-runtime"; import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core"; @@ -20,6 +21,7 @@ import { buildTypingThreadParams } from "./bot/helpers.js"; import type { TelegramInlineButtons } from "./button-types.js"; import { splitTelegramCaption } from "./caption.js"; import { asTelegramClientFetch, createTelegramClientFetch } from "./client-fetch.js"; +import { markTelegramDeliveryErrorVisible } from "./delivery-error.js"; import { resolveTelegramTransport } from "./fetch.js"; import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "./format.js"; import { buildInlineKeyboard } from "./inline-keyboard.js"; @@ -61,6 +63,11 @@ import { resolveMarkdownTableMode, } from "./send.runtime.js"; import { recordSentMessage } from "./sent-message-cache.js"; +import { + buildTelegramStandardFragmentAbort, + buildTelegramStandardTextChunks, + stripTelegramStandardFragmentMarker, +} from "./standard-text.js"; import { maybePersistResolvedTelegramTarget } from "./target-writeback.js"; import { normalizeTelegramChatId, @@ -96,6 +103,8 @@ type TelegramSendOpts = { api?: TelegramApiOverride; retry?: RetryConfig; textMode?: "markdown" | "html"; + /** Use the standard Bot API text method instead of rich messages. */ + standardMessage?: boolean; /** Send audio as voice message instead of audio file. Defaults to false. */ asVoice?: boolean; /** Send video as video note instead of regular video. Defaults to false. */ @@ -104,6 +113,10 @@ type TelegramSendOpts = { silent?: boolean; /** Message ID to reply to (for threading) */ replyToMessageId?: number; + /** Controls whether the reply target applies once or to every chunk. */ + replyToMode?: ReplyToMode; + /** Distinguishes explicit agent targets from ambient reply-mode targets. */ + replyToIdSource?: "explicit" | "implicit"; /** Quote text for Telegram reply_parameters. */ quoteText?: string; /** Forum topic thread ID (for forum supergroups) */ @@ -621,12 +634,47 @@ export async function sendMessageTelegram( type TelegramTextChunk = { text: string; + htmlText?: string; + plainText?: string; }; const sendTelegramTextChunk = async ( chunk: TelegramTextChunk, - params?: TelegramRichMessageContextParams, + params?: TelegramRichMessageContextParams | TelegramThreadScopedParams, ) => { + if (opts.standardMessage === true) { + const standardParams = { + ...params, + ...(opts.silent === true ? { disable_notification: true } : {}), + ...(account.config.linkPreview === false + ? { link_preview_options: { is_disabled: true } } + : {}), + }; + const plainText = chunk.plainText ?? telegramHtmlToPlainTextFallback(chunk.htmlText ?? ""); + if (!chunk.htmlText) { + const result = await requestWithChatNotFound( + () => api.sendMessage(chatId, plainText || chunk.text, standardParams), + "message", + ); + return { result, acceptedParams: params }; + } + const result = await withTelegramHtmlParseFallback({ + label: "message", + verbose: opts.verbose, + requestHtml: (label) => + requestWithChatNotFound( + () => + api.sendMessage(chatId, chunk.htmlText ?? chunk.text, { + ...standardParams, + parse_mode: "HTML", + }), + label, + ), + requestPlain: (label) => + requestWithChatNotFound(() => api.sendMessage(chatId, plainText, standardParams), label), + }); + return { result, acceptedParams: params }; + } const richRawApi = getTelegramRichRawApi(api); const richParams = { ...params, @@ -644,55 +692,111 @@ export async function sendMessageTelegram( return { result, acceptedParams: params }; }; - const buildTextParams = (isLastChunk: boolean) => - hasRichThreadParams || (isLastChunk && replyMarkup) - ? { - ...richThreadParams, - ...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}), - } - : undefined; + const buildTextParams = (isLastChunk: boolean) => { + return opts.standardMessage === true + ? hasThreadParams || (isLastChunk && replyMarkup) + ? { + ...threadParams, + ...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}), + } + : undefined + : hasRichThreadParams || (isLastChunk && replyMarkup) + ? { + ...richThreadParams, + ...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}), + } + : undefined; + }; const sendTelegramTextChunks = async ( chunks: TelegramTextChunk[], context: string, + priorVisibleDelivery = false, ): Promise<{ messageId: string; chatId: string }> => { let lastMessageId = ""; let lastChatId = chatId; let lastAcceptedParams: TelegramThreadScopedParams | undefined; + let lastContextMessage: TelegramMessageLike | undefined; + let lastContextMessageId: number | undefined; let sentChunkCount = 0; + const isFramedStandardBatch = opts.standardMessage === true && chunks.length > 1; for (let index = 0; index < chunks.length; index += 1) { const chunk = chunks[index]; if (!chunk) { continue; } - const { result: res, acceptedParams } = await sendTelegramTextChunk( - chunk, - buildTextParams(index === chunks.length - 1), - ); - const messageId = resolveTelegramMessageIdOrThrow(res, context); - recordSentMessage(chatId, messageId, cfg); + try { + const { result: res, acceptedParams } = await sendTelegramTextChunk( + chunk, + buildTextParams(index === chunks.length - 1), + ); + sentChunkCount += 1; + const messageId = resolveTelegramMessageIdOrThrow(res, context); + recordSentMessage(chatId, messageId, cfg); + if (isFramedStandardBatch) { + lastContextMessage = res; + lastContextMessageId = messageId; + } else { + await recordOutboundMessageForPromptContext({ + cfg, + account, + chatId, + message: res, + messageId, + text: chunk.text, + ...(acceptedParams?.message_thread_id !== undefined + ? { messageThreadId: acceptedParams.message_thread_id } + : {}), + }); + } + lastMessageId = String(messageId); + lastChatId = String(res?.chat?.id ?? chatId); + lastAcceptedParams = acceptedParams; + } catch (error) { + if (sentChunkCount === 0) { + throw error; + } + if (opts.standardMessage === true && chunks.length > 1) { + // Framed prefixes are transport-only: the peer drops them unless the + // end frame arrives. Retire the batch before an unframed fallback. + const abortText = buildTelegramStandardFragmentAbort(chunks[0]?.text ?? ""); + if (abortText) { + await sendTelegramTextChunk( + { text: abortText, plainText: abortText }, + buildTextParams(false), + ).catch(() => undefined); + } + if (!priorVisibleDelivery) { + throw error; + } + } + throw markTelegramDeliveryErrorVisible(error); + } + } + if (isFramedStandardBatch && lastContextMessage && lastContextMessageId != null) { + const logicalText = chunks + .map((chunk) => stripTelegramStandardFragmentMarker(chunk.plainText ?? chunk.text)) + .join(""); + // Transport is complete once the end frame lands. Context persistence must + // not turn that visible logical send into a retryable delivery failure. await recordOutboundMessageForPromptContext({ cfg, account, chatId, - message: res, - messageId, - text: chunk.text, - ...(acceptedParams?.message_thread_id !== undefined - ? { messageThreadId: acceptedParams.message_thread_id } + message: { ...lastContextMessage, text: logicalText }, + messageId: lastContextMessageId, + text: logicalText, + ...(lastAcceptedParams?.message_thread_id !== undefined + ? { messageThreadId: lastAcceptedParams.message_thread_id } : {}), - }); - lastMessageId = String(messageId); - lastChatId = String(res?.chat?.id ?? chatId); - lastAcceptedParams = acceptedParams; - sentChunkCount += 1; + }).catch(() => undefined); } if (lastMessageId) { logTelegramOutboundSendOk({ accountId: account.accountId, chatId: lastChatId, messageId: lastMessageId, - operation: "sendRichMessage", + operation: opts.standardMessage === true ? "sendMessage" : "sendRichMessage", deliveryKind: "text", messageThreadId: lastAcceptedParams?.message_thread_id, replyToMessageId: opts.replyToMessageId, @@ -704,6 +808,15 @@ export async function sendMessageTelegram( }; const buildChunkedTextPlan = (rawText: string): TelegramTextChunk[] => { + if (opts.standardMessage === true) { + return buildTelegramStandardTextChunks(rawText, { tableMode }).map((chunk) => + Object.assign( + { text: chunk.plainText }, + chunk.htmlText ? { htmlText: chunk.htmlText } : {}, + { plainText: chunk.plainText }, + ), + ); + } return splitTelegramRichTextChunks({ text: rawText, textLimit, @@ -935,8 +1048,16 @@ export async function sendMessageTelegram( // If text was too long for a caption, send it as a separate follow-up message. // Use HTML conversion so markdown renders like captions. if (needsSeparateText && followUpText) { - const textResult = await sendChunkedText(followUpText, "text follow-up send"); - return { messageId: textResult.messageId, chatId: resolvedChatId }; + try { + const textResult = await sendTelegramTextChunks( + buildChunkedTextPlan(followUpText), + "text follow-up send", + true, + ); + return { messageId: textResult.messageId, chatId: resolvedChatId }; + } catch (error) { + throw markTelegramDeliveryErrorVisible(error); + } } return { messageId: String(mediaMessageId), chatId: resolvedChatId }; diff --git a/extensions/telegram/src/standard-text.ts b/extensions/telegram/src/standard-text.ts new file mode 100644 index 000000000000..d08070befa26 --- /dev/null +++ b/extensions/telegram/src/standard-text.ts @@ -0,0 +1,207 @@ +import { randomBytes } from "node:crypto"; +import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts"; +import { + markdownToTelegramChunks, + renderTelegramHtmlText, + telegramHtmlToPlainTextFallback, +} from "./format.js"; + +const TELEGRAM_STANDARD_TEXT_LIMIT = 4096; +const TELEGRAM_FRAGMENT_ADMISSION_FLOOR = 4000; +export const TELEGRAM_STANDARD_FRAGMENT_MARKER = "\u2060"; +const TELEGRAM_STANDARD_FRAGMENT_START = "\u200b"; +const TELEGRAM_STANDARD_FRAGMENT_CONTINUATION = "\u200c"; +const TELEGRAM_STANDARD_FRAGMENT_END = "\u200d"; +const TELEGRAM_STANDARD_FRAGMENT_ABORT = "\u2061"; +const TELEGRAM_STANDARD_FRAGMENT_ID_ZERO = "\u200b"; +const TELEGRAM_STANDARD_FRAGMENT_ID_ONE = "\u200c"; +const TELEGRAM_STANDARD_FRAGMENT_ID_BITS = 32; +const TELEGRAM_STANDARD_FRAGMENT_PREFIX_LENGTH = + TELEGRAM_STANDARD_FRAGMENT_MARKER.length + 1 + TELEGRAM_STANDARD_FRAGMENT_ID_BITS; +const TELEGRAM_STANDARD_FRAGMENT_CONTENT_LIMIT = + TELEGRAM_STANDARD_TEXT_LIMIT - TELEGRAM_STANDARD_FRAGMENT_PREFIX_LENGTH; +export const TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS = 32; +export const TELEGRAM_STANDARD_FRAGMENT_MAX_WIRE_CHARS = + TELEGRAM_STANDARD_TEXT_LIMIT * TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS; + +export type TelegramStandardFragmentKind = "start" | "continuation" | "end" | "abort"; + +export type TelegramStandardFragmentFrame = { + batchId: string; + kind: TelegramStandardFragmentKind; +}; + +export type TelegramStandardTextChunk = { + htmlText?: string; + plainText: string; +}; + +let nextTelegramStandardBatchId = randomBytes(4).readUInt32BE(0); + +function createTelegramStandardBatchId(): string { + const value = nextTelegramStandardBatchId; + nextTelegramStandardBatchId = (nextTelegramStandardBatchId + 1) >>> 0; + let encoded = ""; + for (let bit = TELEGRAM_STANDARD_FRAGMENT_ID_BITS - 1; bit >= 0; bit -= 1) { + encoded += + (value >>> bit) & 1 ? TELEGRAM_STANDARD_FRAGMENT_ID_ONE : TELEGRAM_STANDARD_FRAGMENT_ID_ZERO; + } + return encoded; +} + +function resolveTelegramStandardFragmentKindCode(kind: TelegramStandardFragmentKind): string { + if (kind === "start") { + return TELEGRAM_STANDARD_FRAGMENT_START; + } + if (kind === "end") { + return TELEGRAM_STANDARD_FRAGMENT_END; + } + return kind === "abort" + ? TELEGRAM_STANDARD_FRAGMENT_ABORT + : TELEGRAM_STANDARD_FRAGMENT_CONTINUATION; +} + +export function buildTelegramStandardFragmentAbort(text: string): string | undefined { + const frame = resolveTelegramStandardFragmentFrame(text); + if (!frame) { + return undefined; + } + return `${TELEGRAM_STANDARD_FRAGMENT_MARKER}${TELEGRAM_STANDARD_FRAGMENT_ABORT}${frame.batchId}`; +} + +export function frameTelegramStandardTextFragments(contentChunks: readonly string[]): string[] { + if (contentChunks.length < 2) { + return [...contentChunks]; + } + if (contentChunks.length > TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS) { + throw new Error( + `Telegram standard message exceeds the ${TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS}-fragment safety limit`, + ); + } + const batchId = createTelegramStandardBatchId(); + return contentChunks.map((chunk, index) => { + if (chunk.length > TELEGRAM_STANDARD_FRAGMENT_CONTENT_LIMIT) { + throw new Error("Telegram standard message fragment exceeds the sendMessage limit"); + } + const kind = + index === 0 ? "start" : index === contentChunks.length - 1 ? "end" : "continuation"; + return `${TELEGRAM_STANDARD_FRAGMENT_MARKER}${resolveTelegramStandardFragmentKindCode(kind)}${batchId}${chunk}`; + }); +} + +function splitObservableTelegramPlainText(text: string): string[] { + if (text.length <= TELEGRAM_STANDARD_TEXT_LIMIT) { + return [text]; + } + const contentChunks: string[] = []; + let remaining = text; + while (remaining) { + if (remaining.length <= TELEGRAM_STANDARD_FRAGMENT_CONTENT_LIMIT) { + contentChunks.push(remaining); + break; + } + let end = TELEGRAM_STANDARD_FRAGMENT_CONTENT_LIMIT; + if (splitsSurrogatePair(remaining, end)) { + end -= 1; + } + contentChunks.push(remaining.slice(0, end)); + remaining = remaining.slice(end); + } + if (contentChunks.length > TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS) { + throw new Error( + `Telegram standard message exceeds the ${TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS}-fragment safety limit`, + ); + } + return frameTelegramStandardTextFragments(contentChunks); +} + +function splitsSurrogatePair(text: string, index: number): boolean { + const finalCodeUnit = text.charCodeAt(index - 1); + const nextCodeUnit = text.charCodeAt(index); + return ( + finalCodeUnit >= 0xd800 && + finalCodeUnit <= 0xdbff && + nextCodeUnit >= 0xdc00 && + nextCodeUnit <= 0xdfff + ); +} + +export function stripTelegramStandardFragmentMarker(text: string): string { + const frame = resolveTelegramStandardFragmentFrame(text); + if (!frame) { + return text; + } + return text.slice(TELEGRAM_STANDARD_FRAGMENT_PREFIX_LENGTH); +} + +export function resolveTelegramStandardFragmentFrame( + text: string, +): TelegramStandardFragmentFrame | undefined { + if (!text.startsWith(TELEGRAM_STANDARD_FRAGMENT_MARKER)) { + return undefined; + } + const kindCode = text.at(TELEGRAM_STANDARD_FRAGMENT_MARKER.length); + const kind = + kindCode === TELEGRAM_STANDARD_FRAGMENT_START + ? "start" + : kindCode === TELEGRAM_STANDARD_FRAGMENT_CONTINUATION + ? "continuation" + : kindCode === TELEGRAM_STANDARD_FRAGMENT_END + ? "end" + : kindCode === TELEGRAM_STANDARD_FRAGMENT_ABORT + ? "abort" + : undefined; + if (!kind) { + return undefined; + } + const batchId = text.slice( + TELEGRAM_STANDARD_FRAGMENT_MARKER.length + 1, + TELEGRAM_STANDARD_FRAGMENT_PREFIX_LENGTH, + ); + if ( + batchId.length !== TELEGRAM_STANDARD_FRAGMENT_ID_BITS || + batchId + .split("") + .some( + (char) => + char !== TELEGRAM_STANDARD_FRAGMENT_ID_ZERO && char !== TELEGRAM_STANDARD_FRAGMENT_ID_ONE, + ) + ) { + return undefined; + } + return { batchId, kind }; +} + +export function resolveTelegramStandardFragmentKind( + text: string, +): TelegramStandardFragmentKind | undefined { + return resolveTelegramStandardFragmentFrame(text)?.kind; +} + +/** + * Standard peer-bot messages use visible-length chunks so inbound fragment + * admission sees every non-final chunk as one logical Telegram turn. + */ +export function buildTelegramStandardTextChunks( + text: string, + options: { tableMode?: MarkdownTableMode } = {}, +): TelegramStandardTextChunk[] { + const formatted = markdownToTelegramChunks(text, TELEGRAM_STANDARD_TEXT_LIMIT, options); + if (formatted.length === 0) { + return splitObservableTelegramPlainText(text).map((plainText) => ({ plainText })); + } + if (formatted.length <= 1) { + return formatted.map((chunk) => ({ + htmlText: chunk.html, + plainText: telegramHtmlToPlainTextFallback(chunk.html), + })); + } + + const renderedPlainText = telegramHtmlToPlainTextFallback( + renderTelegramHtmlText(text, { tableMode: options.tableMode }), + ); + const observableText = renderedPlainText || text; + return splitObservableTelegramPlainText(observableText).map((plainText) => ({ plainText })); +} + +export const TELEGRAM_STANDARD_FRAGMENT_ADMISSION_FLOOR = TELEGRAM_FRAGMENT_ADMISSION_FLOOR; diff --git a/src/agents/runtime-plan/types.ts b/src/agents/runtime-plan/types.ts index fb4178d40882..7cc018e3a1a8 100644 --- a/src/agents/runtime-plan/types.ts +++ b/src/agents/runtime-plan/types.ts @@ -241,6 +241,7 @@ export type AgentRuntimeReplyPayload = { question: string; }; replyToId?: string; + replyToIdSource?: "explicit" | "implicit"; replyToTag?: boolean; replyToCurrent?: boolean; audioAsVoice?: boolean; diff --git a/src/auto-reply/get-reply-options.types.ts b/src/auto-reply/get-reply-options.types.ts index 073476a38e8c..65ccceb33e9f 100644 --- a/src/auto-reply/get-reply-options.types.ts +++ b/src/auto-reply/get-reply-options.types.ts @@ -1,3 +1,4 @@ +import type { ReplyToMode } from "../config/types.js"; /** Public option types for reply generation callbacks, streaming, and delivery policy. */ import type { ImageContent } from "../llm/types.js"; import type { PromptImageOrderEntry } from "../media/prompt-image-order.js"; @@ -214,6 +215,14 @@ export type GetReplyOptions = { queuedDeliveryCorrelations?: QueuedReplyDeliveryCorrelation[]; /** Tracks ownership transfer when this turn later drains as a queued followup. */ queuedFollowupLifecycle?: QueuedReplyLifecycle; + /** Applies source-channel delivery metadata when a queued follow-up later drains. */ + queuedDeliveryPayloadTransform?: (payload: ReplyPayload) => ReplyPayload; + /** Preserves source-channel reply fan-out policy for queued delivery. */ + queuedDeliveryReplyToMode?: ReplyToMode; + /** Commits source-channel delivery state after a queued payload is visibly routed. */ + queuedDeliveryPayloadDidDeliver?: (payload: ReplyPayload) => void; + /** Re-establishes source-owned async context while a queued follow-up drains. */ + queuedExecutionContext?: (run: () => Promise) => Promise; /** Allow channel-owned progress UI while final/source reply delivery remains message-tool-only. */ allowProgressCallbacksWhenSourceDeliverySuppressed?: boolean; /** Called when a suppressed source reply mode observes visible delivery through another path. */ diff --git a/src/auto-reply/reply-payload.ts b/src/auto-reply/reply-payload.ts index 42a5a1a58e86..3aed02d3dbfa 100644 --- a/src/auto-reply/reply-payload.ts +++ b/src/auto-reply/reply-payload.ts @@ -28,6 +28,8 @@ export type ReplyPayload = { question: string; }; replyToId?: string; + /** Internal reply-policy provenance; implicit ids honor single-use reply modes. */ + replyToIdSource?: "explicit" | "implicit"; replyToTag?: boolean; /** True when [[reply_to_current]] was present but not yet mapped to a message id. */ replyToCurrent?: boolean; diff --git a/src/auto-reply/reply/commands-private-route.ts b/src/auto-reply/reply/commands-private-route.ts index 793d9482b64a..e962082fe936 100644 --- a/src/auto-reply/reply/commands-private-route.ts +++ b/src/auto-reply/reply/commands-private-route.ts @@ -107,7 +107,9 @@ export async function deliverPrivateCommandReply(params: { }), ), ); - return results.some((result) => result.status === "fulfilled" && result.value.ok); + return results.some( + (result) => result.status === "fulfilled" && (result.value.ok || result.value.delivered), + ); } /** Reads the command message thread id from command context. */ diff --git a/src/auto-reply/reply/dispatch-acp-delivery.test.ts b/src/auto-reply/reply/dispatch-acp-delivery.test.ts index 7b3079e4d30d..9e514a8f376b 100644 --- a/src/auto-reply/reply/dispatch-acp-delivery.test.ts +++ b/src/auto-reply/reply/dispatch-acp-delivery.test.ts @@ -19,10 +19,13 @@ const deliveryMocks = vi.hoisted(() => ({ _params: unknown, ): Promise<{ ok: boolean; + delivered?: boolean; + error?: string; messageId?: string; + partialFailure?: boolean; suppressed?: boolean; reason?: string; - }> => ({ ok: true, messageId: "mock-message" }), + }> => ({ ok: true, delivered: true, messageId: "mock-message" }), ), runMessageAction: vi.fn(async (_params: unknown) => ({ ok: true as const })), })); @@ -153,7 +156,11 @@ async function expectVisibleChatBlockRoutesToAccount( describe("createAcpDispatchDeliveryCoordinator", () => { beforeEach(() => { deliveryMocks.routeReply.mockClear(); - deliveryMocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock-message" }); + deliveryMocks.routeReply.mockResolvedValue({ + ok: true, + delivered: true, + messageId: "mock-message", + }); deliveryMocks.runMessageAction.mockClear(); deliveryMocks.runMessageAction.mockResolvedValue({ ok: true as const }); channelPluginMocks.getChannelPlugin.mockClear(); @@ -833,6 +840,36 @@ describe("createAcpDispatchDeliveryCoordinator", () => { expect(coordinator.getRoutedCounts().block).toBe(1); }); + it("records partial routed block delivery as visible and failed without fallback", async () => { + deliveryMocks.routeReply.mockResolvedValueOnce({ + ok: false, + delivered: true, + partialFailure: true, + error: "second chunk failed", + messageId: "visible-1", + }); + const coordinator = createVisibleChatAcpCoordinator(createAcpTestConfig()); + + const delivered = await coordinator.deliver("block", { text: "hello" }, { skipTts: true }); + + expect(delivered).toBe(true); + expect(coordinator.hasDeliveredVisibleText()).toBe(true); + expect(coordinator.hasFailedVisibleTextDelivery()).toBe(true); + expect(coordinator.getRoutedCounts().block).toBe(1); + }); + + it("does not count a successful routed no-op as ACP delivery", async () => { + deliveryMocks.routeReply.mockResolvedValueOnce({ ok: true, delivered: false }); + const coordinator = createVisibleChatAcpCoordinator(createAcpTestConfig()); + + const delivered = await coordinator.deliver("block", { text: "hello" }, { skipTts: true }); + + expect(delivered).toBe(false); + expect(coordinator.hasDeliveredVisibleText()).toBe(false); + expect(coordinator.hasFailedVisibleTextDelivery()).toBe(false); + expect(coordinator.getRoutedCounts().block).toBe(0); + }); + it("treats hook-suppressed routed ACP block text as handled", async () => { deliveryMocks.routeReply.mockResolvedValueOnce({ ok: true, diff --git a/src/auto-reply/reply/dispatch-acp-delivery.ts b/src/auto-reply/reply/dispatch-acp-delivery.ts index 9bb6400bb477..c8c952ff0d39 100644 --- a/src/auto-reply/reply/dispatch-acp-delivery.ts +++ b/src/auto-reply/reply/dispatch-acp-delivery.ts @@ -154,6 +154,7 @@ type AcpDispatchDeliveryState = { deliveredFinalReply: boolean; deliveredVisibleText: boolean; failedVisibleTextDelivery: boolean; + requiresVisibleTextFallback: boolean; queuedDirectVisibleTextDeliveries: number; settledDirectVisibleText: boolean; routedCounts: Record; @@ -176,6 +177,7 @@ export type AcpDispatchDeliveryCoordinator = { hasDeliveredFinalReply: () => boolean; hasDeliveredVisibleText: () => boolean; hasFailedVisibleTextDelivery: () => boolean; + requiresVisibleTextFallback: () => boolean; getRoutedCounts: () => Record; applyRoutedCounts: (counts: Record) => void; }; @@ -232,6 +234,7 @@ export function createAcpDispatchDeliveryCoordinator(params: { deliveredFinalReply: false, deliveredVisibleText: false, failedVisibleTextDelivery: false, + requiresVisibleTextFallback: false, queuedDirectVisibleTextDeliveries: 0, settledDirectVisibleText: false, routedCounts: { @@ -263,6 +266,7 @@ export function createAcpDispatchDeliveryCoordinator(params: { const failedVisibleCount = failedCounts.block + failedCounts.final; if (failedVisibleCount > 0) { state.failedVisibleTextDelivery = true; + state.requiresVisibleTextFallback = true; } if (state.queuedDirectVisibleTextDeliveries > failedVisibleCount) { state.deliveredVisibleText = true; @@ -438,11 +442,13 @@ export function createAcpDispatchDeliveryCoordinator(params: { if (!result.ok) { if (tracksVisibleText) { state.failedVisibleTextDelivery = true; + if (!result.delivered) { + state.requiresVisibleTextFallback = true; + } } logVerbose( `dispatch-acp: route-reply (acp/${kind}) failed: ${result.error ?? "unknown error"}`, ); - return false; } if (result.suppressed) { if (kind === "final") { @@ -453,6 +459,9 @@ export function createAcpDispatchDeliveryCoordinator(params: { } return true; } + if (!result.delivered) { + return false; + } if (kind === "tool" && meta?.toolCallId && result.messageId) { state.toolMessageByCallId.set(meta.toolCallId, { channel: params.originatingChannel, @@ -496,6 +505,7 @@ export function createAcpDispatchDeliveryCoordinator(params: { state.settledDirectVisibleText = false; } else if (!delivered && tracksVisibleText) { state.failedVisibleTextDelivery = true; + state.requiresVisibleTextFallback = true; } if (kind === "block" && delivered) { hasPendingDirectBlockReplyDelivery = true; @@ -515,6 +525,7 @@ export function createAcpDispatchDeliveryCoordinator(params: { hasDeliveredFinalReply: () => state.deliveredFinalReply, hasDeliveredVisibleText: () => state.deliveredVisibleText, hasFailedVisibleTextDelivery: () => state.failedVisibleTextDelivery, + requiresVisibleTextFallback: () => state.requiresVisibleTextFallback, getRoutedCounts: () => ({ ...state.routedCounts }), applyRoutedCounts: (counts) => { counts.tool += state.routedCounts.tool; diff --git a/src/auto-reply/reply/dispatch-acp.test.ts b/src/auto-reply/reply/dispatch-acp.test.ts index 82c49e7e2f4f..f5bbaf6d2b56 100644 --- a/src/auto-reply/reply/dispatch-acp.test.ts +++ b/src/auto-reply/reply/dispatch-acp.test.ts @@ -41,8 +41,13 @@ const policyMocks = vi.hoisted(() => ({ const routeMocks = vi.hoisted(() => ({ routeReply: vi.fn< - (_params: unknown) => Promise<{ ok: true; messageId: string } | { ok: false; error: string }> - >(async () => ({ ok: true, messageId: "mock" })), + ( + _params: unknown, + ) => Promise< + | { ok: true; delivered: true; messageId: string } + | { ok: false; error: string; delivered?: boolean; partialFailure?: boolean } + > + >(async () => ({ ok: true, delivered: true, messageId: "mock" })), })); const channelPluginMocks = vi.hoisted(() => ({ @@ -445,7 +450,7 @@ describe("tryDispatchAcpReply", () => { policyMocks.resolveAcpAgentPolicyError.mockReset(); policyMocks.resolveAcpAgentPolicyError.mockReturnValue(null); routeMocks.routeReply.mockReset(); - routeMocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" }); + routeMocks.routeReply.mockResolvedValue({ ok: true, delivered: true, messageId: "mock" }); channelPluginMocks.getChannelPlugin.mockClear(); messageActionMocks.runMessageAction.mockReset(); messageActionMocks.runMessageAction.mockResolvedValue({ ok: true as const }); @@ -575,7 +580,11 @@ describe("tryDispatchAcpReply", () => { it("edits ACP tool lifecycle updates in place when supported", async () => { setReadyAcpResolution(); mockToolLifecycleTurn("call-1"); - routeMocks.routeReply.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-1" }); + routeMocks.routeReply.mockResolvedValueOnce({ + ok: true, + delivered: true, + messageId: "tool-msg-1", + }); const { dispatcher } = createDispatcher(); await runDispatch({ @@ -596,8 +605,12 @@ describe("tryDispatchAcpReply", () => { setReadyAcpResolution(); mockToolLifecycleTurn("call-2"); routeMocks.routeReply - .mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2" }) - .mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2-fallback" }); + .mockResolvedValueOnce({ ok: true, delivered: true, messageId: "tool-msg-2" }) + .mockResolvedValueOnce({ + ok: true, + delivered: true, + messageId: "tool-msg-2-fallback", + }); messageActionMocks.runMessageAction.mockRejectedValueOnce(new Error("edit unsupported")); const { dispatcher } = createDispatcher(); @@ -1710,6 +1723,28 @@ describe("tryDispatchAcpReply", () => { expect(routeMocks.routeReply).toHaveBeenCalledTimes(1); }); + it("does not replay a routed ACP block after partial visible delivery", async () => { + setReadyAcpResolution(); + ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" }); + routeMocks.routeReply.mockResolvedValue({ + ok: false, + delivered: true, + partialFailure: true, + error: "second chunk failed", + }); + mockRoutedTextTurn("partially visible block"); + const { dispatcher } = createDispatcher(); + + await runDispatch({ + bodyForAgent: "run acp", + dispatcher, + shouldRouteToOriginating: true, + }); + + expect(routeMocks.routeReply).toHaveBeenCalledTimes(1); + expect(routePayload().text).toBe("partially visible block"); + }); + it("routes default ACP text as one final reply to Discord", async () => { setReadyAcpResolution(); ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" }); diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index da688044fa1d..797b7258231e 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -266,7 +266,7 @@ async function finalizeAcpTurnOutput(params: { }): Promise { await params.delivery.settleVisibleText(); let queuedFinal = - params.delivery.hasDeliveredVisibleText() && !params.delivery.hasFailedVisibleTextDelivery(); + params.delivery.hasDeliveredVisibleText() && !params.delivery.requiresVisibleTextFallback(); const ttsMode = resolveConfiguredTtsMode(params.cfg, { agentId: params.agentId, channelId: params.ttsChannel, @@ -328,7 +328,7 @@ async function finalizeAcpTurnOutput(params: { accumulatedVisibleBlockText.trim().length > 0 && !finalMediaDelivered && !params.delivery.hasDeliveredFinalReply() && - (!params.delivery.hasDeliveredVisibleText() || params.delivery.hasFailedVisibleTextDelivery()); + (!params.delivery.hasDeliveredVisibleText() || params.delivery.requiresVisibleTextFallback()); if (shouldDeliverTextFallback) { const delivered = await params.delivery.deliver( "final", diff --git a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts index b843c34df364..5b689890e4fa 100644 --- a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts @@ -56,7 +56,11 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { clearAgentHarnesses(); setDiscordTestRegistry(); resetInboundDedupe(); - mocks.routeReply.mockReset().mockResolvedValue({ ok: true, messageId: "mock" }); + mocks.routeReply.mockReset().mockResolvedValue({ + ok: true, + delivered: true, + messageId: "mock", + }); mocks.tryFastAbortFromMessage.mockReset().mockResolvedValue({ handled: false, aborted: false, @@ -194,7 +198,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({ existing: sessionStoreMocks.currentEntry, }); - mocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" }); + mocks.routeReply.mockResolvedValue({ ok: true, delivered: true, messageId: "mock" }); const result = await dispatchReplyFromConfig({ ctx: createHookCtx(), diff --git a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts index 3d27a70aa702..0b2493302257 100644 --- a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts +++ b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts @@ -15,12 +15,17 @@ import { } from "../../test-utils/channel-plugins.js"; import type { ReplyPayload } from "../types.js"; import type { ReplyDispatcher } from "./reply-dispatcher.js"; +import type { RouteReplyResult } from "./route-reply.js"; import { buildTestCtx } from "./test-ctx.js"; type AbortResult = { handled: boolean; aborted: boolean; stoppedSubagents?: number }; const mocks = vi.hoisted(() => ({ - routeReply: vi.fn(async (_params: unknown) => ({ ok: true, messageId: "mock" })), + routeReply: vi.fn<(_params: unknown) => Promise>(async () => ({ + ok: true, + delivered: true, + messageId: "mock", + })), tryFastAbortFromMessage: vi.fn<() => Promise>(async () => ({ handled: false, aborted: false, diff --git a/src/auto-reply/reply/dispatch-from-config.stale-recovery.test.ts b/src/auto-reply/reply/dispatch-from-config.stale-recovery.test.ts index 22331911219c..53f7fa476891 100644 --- a/src/auto-reply/reply/dispatch-from-config.stale-recovery.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.stale-recovery.test.ts @@ -32,7 +32,7 @@ describe("dispatchReplyFromConfig stale visible admission recovery", () => { resetPluginTtsAndThreadMocks(); runtimePluginMocks.ensureRuntimePluginsLoaded.mockReset(); mocks.routeReply.mockReset(); - mocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" }); + mocks.routeReply.mockResolvedValue({ ok: true, delivered: true, messageId: "mock" }); mocks.tryFastAbortFromMessage.mockReset(); setNoAbort(); diagnosticMocks.requestStuckDiagnosticSessionRecovery.mockReset(); diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index eba55335ac3f..cd8130e57ea0 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -43,6 +43,7 @@ import { type ReplyDispatchBeforeDeliver, type ReplyDispatcher, } from "./reply-dispatcher.js"; +import type { RouteReplyResult } from "./route-reply.js"; import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js"; import { buildTestCtx } from "./test-ctx.js"; @@ -52,7 +53,11 @@ type ResolveInboundConversationParams = Parameters< >[0]; const mocks = vi.hoisted(() => ({ - routeReply: vi.fn(async (_params: unknown) => ({ ok: true, messageId: "mock" })), + routeReply: vi.fn<(_params: unknown) => Promise>(async () => ({ + ok: true, + delivered: true, + messageId: "mock", + })), tryFastAbortFromMessage: vi.fn<() => Promise>(async () => ({ handled: false, aborted: false, @@ -965,7 +970,7 @@ describe("dispatchReplyFromConfig", () => { replyRunTesting.resetReplyRunRegistry(); resetInboundDedupe(); mocks.routeReply.mockReset(); - mocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" }); + mocks.routeReply.mockResolvedValue({ ok: true, delivered: true, messageId: "mock" }); acpMocks.listAcpSessionEntries.mockReset().mockResolvedValue([]); diagnosticMocks.logMessageQueued.mockClear(); diagnosticMocks.logMessageProcessed.mockClear(); @@ -1379,6 +1384,83 @@ describe("dispatchReplyFromConfig", () => { ); }); + it("treats a partially delivered routed final as visible and handled", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + mocks.routeReply.mockResolvedValueOnce({ + ok: false, + delivered: true, + error: "second chunk failed", + }); + + const result = await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "slack", + Surface: "slack", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:999", + SessionKey: "agent:main:telegram:group:999", + }), + cfg: emptyConfig, + dispatcher, + replyResolver: async () => ({ text: "Partially delivered routed reply" }), + }); + + expect(result.queuedFinal).toBe(true); + expect(result.counts.final).toBe(1); + expect(result.noVisibleReplyFallbackEligible).toBeUndefined(); + }); + + it("keeps an explicit routed no-op eligible for visible fallback", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + mocks.routeReply.mockResolvedValueOnce({ ok: true, delivered: false }); + + const result = await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "slack", + Surface: "slack", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:999", + SessionKey: "agent:main:telegram:group:999", + }), + cfg: emptyConfig, + dispatcher, + replyResolver: async () => ({ text: "Normalized no-op" }), + }); + + expect(result.queuedFinal).toBe(false); + expect(result.counts.final).toBe(0); + expect(result.noVisibleReplyFallbackEligible).toBe(true); + }); + + it("treats intentional routed suppression as handled but not visible", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + mocks.routeReply.mockResolvedValueOnce({ + ok: true, + delivered: false, + suppressed: true, + }); + + const result = await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "slack", + Surface: "slack", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:999", + SessionKey: "agent:main:telegram:group:999", + }), + cfg: emptyConfig, + dispatcher, + replyResolver: async () => ({ text: "Suppressed by hook" }), + }); + + expect(result.queuedFinal).toBe(true); + expect(result.counts.final).toBe(0); + expect(result.noVisibleReplyFallbackEligible).toBeUndefined(); + }); + it("mirrors the delivered ownerless Slack text after dispatcher hook rewrites", async () => { setNoAbort(); const dispatcher = createDispatcher(); @@ -7503,7 +7585,7 @@ describe("before_dispatch hook", () => { beforeEach(() => { resetInboundDedupe(); mocks.routeReply.mockReset(); - mocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" }); + mocks.routeReply.mockResolvedValue({ ok: true, delivered: true, messageId: "mock" }); threadInfoMocks.parseSessionThreadInfo.mockReset(); threadInfoMocks.parseSessionThreadInfo.mockImplementation(parseGenericThreadSessionInfo); ttsMocks.state.synthesizeFinalAudio = false; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index c88002d4f3bd..97645d5dd2b6 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -1661,8 +1661,16 @@ export async function dispatchReplyFromConfig( }); }; - const isRoutedReplyDelivered = (result: { ok: boolean; suppressed?: boolean }) => - result.ok && result.suppressed !== true; + const isRoutedReplyDelivered = (result: { + ok: boolean; + delivered?: boolean; + suppressed?: boolean; + }) => result.delivered === true; + const isRoutedReplyHandled = (result: { + ok: boolean; + delivered?: boolean; + suppressed?: boolean; + }) => isRoutedReplyDelivered(result) || result.suppressed === true; /** * Helper to send a payload via route-reply (async). @@ -1708,7 +1716,7 @@ export async function dispatchReplyFromConfig( `dispatch-from-config: route-reply (plugin binding notice) failed: ${result.error ?? "unknown error"}`, ); } - return result.ok; + return isRoutedReplyHandled(result); } markInboundDedupeReplayUnsafe(); return mode === "additive" @@ -2121,7 +2129,7 @@ export async function dispatchReplyFromConfig( } satisfies ReplyPayload; const result = await routeReplyToOriginating(payload); if (result) { - queuedFinal = result.ok; + queuedFinal = isRoutedReplyHandled(result); if (isRoutedReplyDelivered(result)) { routedFinalCount += 1; } @@ -2333,7 +2341,7 @@ export async function dispatchReplyFromConfig( }); } return { - queuedFinal: result.ok, + queuedFinal: isRoutedReplyHandled(result), routedFinalCount: isRoutedReplyDelivered(result) ? 1 : 0, }; } @@ -3326,7 +3334,7 @@ export async function dispatchReplyFromConfig( kind: "final", }); if (result) { - queuedFinal = result.ok || queuedFinal; + queuedFinal = isRoutedReplyHandled(result) || queuedFinal; if (isRoutedReplyDelivered(result)) { routedFinalCount += 1; } diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 2502b6afd53f..738f3f5d5c3c 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -11,6 +11,7 @@ import { createUserTurnTranscriptRecorder, type PersistedUserTurnMessage, } from "../../sessions/user-turn-transcript.js"; +import type { ReplyPayload } from "../reply-payload.js"; import type { FollowupRun, QueueSettings } from "./queue.js"; const runEmbeddedAgentMock = vi.fn(); @@ -3592,8 +3593,160 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { expect(onBlockReply).not.toHaveBeenCalled(); }); + it("commits queued delivery state only after a payload survives routing", async () => { + routeReplyMock.mockResolvedValue({ ok: true, delivered: true }); + resolveProviderFollowupFallbackRouteMock.mockImplementation( + (params: { context?: { payload?: ReplyPayload } }) => + params.context?.payload?.text === "drop me" + ? { route: "drop", reason: "already delivered out of band" } + : undefined, + ); + let implicitReplyAvailable = true; + const queuedDeliveryPayloadTransform = vi.fn((payload: ReplyPayload) => + implicitReplyAvailable + ? { ...payload, replyToId: "root", replyToIdSource: "implicit" as const } + : payload, + ); + const queuedDeliveryPayloadDidDeliver = vi.fn((payload: ReplyPayload) => { + if (payload.replyToIdSource === "implicit") { + implicitReplyAvailable = false; + } + }); + + await runMessagingCase({ + agentResult: { payloads: [{ text: "drop me" }, { text: "deliver me" }] }, + queued: { + ...baseQueuedRun("webchat"), + originatingChannel: "discord", + originatingTo: "channel:C1", + queuedDeliveryPayloadTransform, + queuedDeliveryReplyToMode: "first", + queuedDeliveryPayloadDidDeliver, + } as FollowupRun, + }); + + expect(queuedDeliveryPayloadTransform).toHaveBeenCalledTimes(2); + expect(routeReplyMock).toHaveBeenCalledTimes(1); + expect(requireMockCallArg(routeReplyMock, 0).payload).toMatchObject({ + text: "deliver me", + replyToId: "root", + replyToIdSource: "implicit", + }); + expect(requireMockCallArg(routeReplyMock, 0).replyToMode).toBe("first"); + expect(queuedDeliveryPayloadDidDeliver).toHaveBeenCalledTimes(1); + }); + + it("does not commit queued delivery state for a successful no-send route", async () => { + routeReplyMock + .mockResolvedValueOnce({ ok: true, delivered: false }) + .mockResolvedValueOnce({ ok: true, delivered: true }); + let implicitReplyAvailable = true; + const queuedDeliveryPayloadTransform = vi.fn((payload: ReplyPayload) => + implicitReplyAvailable + ? { ...payload, replyToId: "root", replyToIdSource: "implicit" as const } + : payload, + ); + const queuedDeliveryPayloadDidDeliver = vi.fn((payload: ReplyPayload) => { + if (payload.replyToIdSource === "implicit") { + implicitReplyAvailable = false; + } + }); + + await runMessagingCase({ + agentResult: { payloads: [{ text: "hidden" }, { text: "visible" }] }, + queued: { + ...baseQueuedRun("webchat"), + originatingChannel: "discord", + originatingTo: "channel:C1", + queuedDeliveryPayloadTransform, + queuedDeliveryPayloadDidDeliver, + } as FollowupRun, + }); + + expect(routeReplyMock).toHaveBeenCalledTimes(2); + expect(requireMockCallArg(routeReplyMock, 1).payload).toMatchObject({ + text: "visible", + replyToId: "root", + replyToIdSource: "implicit", + }); + expect(queuedDeliveryPayloadDidDeliver).toHaveBeenCalledTimes(1); + expect(queuedDeliveryPayloadDidDeliver).toHaveBeenCalledWith( + expect.objectContaining({ text: "visible" }), + ); + }); + + it("commits queued delivery state without fallback after a partial send", async () => { + routeReplyMock.mockResolvedValue({ + ok: false, + delivered: true, + partialFailure: true, + error: "second chunk failed", + }); + const queuedDeliveryPayloadDidDeliver = vi.fn(); + + const { onBlockReply } = await runMessagingCase({ + agentResult: { payloads: [{ text: "partially visible" }] }, + queued: { + ...baseQueuedRun("discord"), + originatingChannel: "discord", + originatingTo: "channel:C1", + queuedDeliveryPayloadDidDeliver, + } as FollowupRun, + }); + + expect(queuedDeliveryPayloadDidDeliver).toHaveBeenCalledTimes(1); + expect(onBlockReply).not.toHaveBeenCalled(); + }); + + it("does not report cross-channel failure after an earlier partial visible delivery", async () => { + routeReplyMock + .mockResolvedValueOnce({ + ok: false, + delivered: true, + partialFailure: true, + error: "second chunk failed", + }) + .mockResolvedValueOnce({ ok: false, delivered: false, error: "provider unavailable" }); + const queuedDeliveryPayloadDidDeliver = vi.fn(); + + const { onBlockReply } = await runMessagingCase({ + agentResult: { payloads: [{ text: "partially visible" }, { text: "fully failed" }] }, + queued: { + ...baseQueuedRun("webchat"), + originatingChannel: "discord", + originatingTo: "channel:C1", + queuedDeliveryPayloadDidDeliver, + } as FollowupRun, + }); + + expect(queuedDeliveryPayloadDidDeliver).toHaveBeenCalledTimes(1); + expect(onBlockReply).not.toHaveBeenCalled(); + }); + + it("does not commit queued delivery state when dispatcher only accepts the payload", async () => { + resolveProviderFollowupFallbackRouteMock.mockReturnValue({ route: "dispatcher" }); + const queuedDeliveryPayloadDidDeliver = vi.fn(); + + const { onBlockReply } = await runMessagingCase({ + agentResult: { payloads: [{ text: "dispatcher queued" }] }, + queued: { + ...baseQueuedRun("webchat"), + queuedDeliveryPayloadDidDeliver, + } as FollowupRun, + }); + + expect(onBlockReply).toHaveBeenCalledWith( + expect.objectContaining({ text: "dispatcher queued" }), + ); + expect(queuedDeliveryPayloadDidDeliver).not.toHaveBeenCalled(); + }); + it("suppresses exact NO_REPLY followups without origin or dispatcher delivery", async () => { const typing = createMockTypingController(); + const queuedDeliveryPayloadTransform = vi.fn((payload: ReplyPayload) => ({ + ...payload, + channelData: { telegram: { standardMessage: true } }, + })); runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: ` ${DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText} ` }], meta: {}, @@ -3604,9 +3757,16 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { defaultModel: "anthropic/claude-opus-4-6", }); - await runner(createQueuedRun({ originatingChannel: undefined, originatingTo: undefined })); + await runner( + createQueuedRun({ + originatingChannel: undefined, + originatingTo: undefined, + queuedDeliveryPayloadTransform, + }), + ); expect(routeReplyMock).not.toHaveBeenCalled(); + expect(queuedDeliveryPayloadTransform).not.toHaveBeenCalled(); expect(typing.markRunComplete).toHaveBeenCalledTimes(1); expect(typing.markDispatchIdle).toHaveBeenCalledTimes(1); }); diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 62b92d9b6b04..6402e0b6cbba 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -368,7 +368,12 @@ export function createFollowupRunner(params: { } await opts.onBlockReply(payload); }; - for (const payload of sendablePayloads) { + for (const sourcePayload of sendablePayloads) { + // Transform at routing time so channel-owned single-use state observes the + // previous payload's actual delivery outcome, not just its queue position. + const payload = queued.queuedDeliveryPayloadTransform + ? queued.queuedDeliveryPayloadTransform(sourcePayload) + : sourcePayload; const providerRoute = deliveryPlan.resolveFollowupRoute({ payload, originatingChannel, @@ -411,6 +416,7 @@ export function createFollowupRunner(params: { requesterSenderUsername: queued.run.senderUsername, requesterSenderE164: queued.run.senderE164, threadId: queued.originatingThreadId, + replyToMode: queued.queuedDeliveryReplyToMode, cfg: runtimeConfig, mirror: hasTranscriptOwner ? false : options.mirror, replyKind, @@ -419,22 +425,43 @@ export function createFollowupRunner(params: { if (!result.ok) { const errorMsg = result.error ?? "unknown error"; logVerbose(`followup queue: route-reply failed: ${errorMsg}`); - const provider = resolveOriginMessageProvider({ - provider: queued.run.messageProvider, - }); - const origin = resolveOriginMessageProvider({ - originatingChannel, - }); - if (opts?.onBlockReply) { - if (origin && origin === provider) { - await sendDispatcherPayload(payload); - } else { - crossChannelRouteFailureNeedsNotice = true; + if (result.delivered) { + const provider = resolveOriginMessageProvider({ + provider: queued.run.messageProvider, + }); + const origin = resolveOriginMessageProvider({ + originatingChannel, + }); + if (origin && provider && origin !== provider) { + routedAnyCrossChannelPayloadToOrigin = true; } + defaultRuntime.error?.( + `followup queue: route-reply partially failed after visible delivery: ${errorMsg}`, + ); + queued.queuedDeliveryPayloadDidDeliver?.(payload); } else { - defaultRuntime.error?.(`followup queue: route-reply failed: ${errorMsg}`); + const provider = resolveOriginMessageProvider({ + provider: queued.run.messageProvider, + }); + const origin = resolveOriginMessageProvider({ + originatingChannel, + }); + if (opts?.onBlockReply) { + if (origin && origin === provider) { + await sendDispatcherPayload(payload); + } else { + crossChannelRouteFailureNeedsNotice = true; + } + } else { + defaultRuntime.error?.(`followup queue: route-reply failed: ${errorMsg}`); + } } } else { + if (result.partialFailure) { + defaultRuntime.error?.( + `followup queue: route-reply partially failed after visible delivery: ${result.error ?? "unknown error"}`, + ); + } const provider = resolveOriginMessageProvider({ provider: queued.run.messageProvider, }); @@ -444,6 +471,9 @@ export function createFollowupRunner(params: { if (origin && provider && origin !== provider) { routedAnyCrossChannelPayloadToOrigin = true; } + if (result.delivered) { + queued.queuedDeliveryPayloadDidDeliver?.(payload); + } } } else if (deliveryRoute === "dispatcher") { await sendDispatcherPayload(payload); @@ -464,7 +494,7 @@ export function createFollowupRunner(params: { } }; - return async (queued: FollowupRun) => { + const runQueuedFollowup = async (queued: FollowupRun) => { if (isFollowupRunAborted(queued)) { completeFollowupRunLifecycle(queued); typing.markRunComplete(); @@ -1348,4 +1378,8 @@ export function createFollowupRunner(params: { typing.markDispatchIdle(); } }; + return async (queued: FollowupRun) => + queued.queuedExecutionContext + ? await queued.queuedExecutionContext(() => runQueuedFollowup(queued)) + : await runQueuedFollowup(queued); } diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 55c65ca0d11d..87f4faa74c05 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -2134,7 +2134,10 @@ describe("runPreparedReply media-only handling", () => { await runPreparedReply( baseParams({ - opts: { abortSignal: abortController.signal }, + opts: { + abortSignal: abortController.signal, + queuedFollowupLifecycle: { onComplete: vi.fn(), onEnqueued: vi.fn() }, + }, ctx: { Body: "@bot keep this", RawBody: "@bot keep this", diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 57901cb0ab1b..6b543f55ceec 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -1260,6 +1260,10 @@ export async function runPreparedReply( ...(queuedFollowupAbortSignal ? { abortSignal: queuedFollowupAbortSignal } : {}), deliveryCorrelations: opts?.queuedDeliveryCorrelations, queuedLifecycle: opts?.queuedFollowupLifecycle, + queuedDeliveryPayloadTransform: opts?.queuedDeliveryPayloadTransform, + queuedDeliveryReplyToMode: opts?.queuedDeliveryReplyToMode, + queuedDeliveryPayloadDidDeliver: opts?.queuedDeliveryPayloadDidDeliver, + queuedExecutionContext: opts?.queuedExecutionContext, messageId: sessionCtx.MessageSidFull ?? sessionCtx.MessageSid, summaryLine: baseBodyTrimmedRaw, enqueuedAt: Date.now(), diff --git a/src/auto-reply/reply/queue.collect.test.ts b/src/auto-reply/reply/queue.collect.test.ts index dae7f01f48a7..45356e3e1003 100644 --- a/src/auto-reply/reply/queue.collect.test.ts +++ b/src/auto-reply/reply/queue.collect.test.ts @@ -699,14 +699,33 @@ describe("followup queue collect routing", () => { cap: 2, dropPolicy: "summarize", }; + const queuedDeliveryPayloadTransform: NonNullable< + FollowupRun["queuedDeliveryPayloadTransform"] + > = vi.fn((payload) => payload); + const queuedDeliveryPayloadDidDeliver: NonNullable< + FollowupRun["queuedDeliveryPayloadDidDeliver"] + > = vi.fn(); + const queuedExecutionContext: NonNullable = vi.fn( + async (run) => await run(), + ); enqueueFollowupRun( key, - createRun({ - prompt: "first", - originatingChannel: "slack", - originatingTo: "channel:A", - }), + { + ...createRun({ + prompt: "first", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingAccountId: "account-a", + originatingThreadId: "thread-a", + originatingReplyToId: "reply-a", + originatingChatType: "channel", + }), + queuedDeliveryPayloadTransform, + queuedDeliveryReplyToMode: "first", + queuedDeliveryPayloadDidDeliver, + queuedExecutionContext, + }, settings, ); enqueueFollowupRun( @@ -736,6 +755,153 @@ describe("followup queue collect routing", () => { expect(calls[1]?.prompt).toBe("third"); expect(calls[2]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); expect(calls[2]?.prompt).toContain("- first"); + expect(calls[2]?.queuedDeliveryPayloadTransform).toBe(queuedDeliveryPayloadTransform); + expect(calls[2]?.queuedDeliveryReplyToMode).toBe("first"); + expect(calls[2]?.queuedDeliveryPayloadDidDeliver).toBe(queuedDeliveryPayloadDidDeliver); + expect(calls[2]?.queuedExecutionContext).toBe(queuedExecutionContext); + expect(calls[2]).toMatchObject({ + originatingChannel: "slack", + originatingTo: "channel:A", + originatingAccountId: "account-a", + originatingThreadId: "thread-a", + originatingReplyToId: "reply-a", + originatingChatType: "channel", + }); + }); + + it("summarizes mixed-route overflow sources per route", async () => { + const key = `test-collect-mixed-summary-routes-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= 4) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 2, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ prompt: "first", originatingChannel: "slack", originatingTo: "channel:A" }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ prompt: "second", originatingChannel: "telegram", originatingTo: "chat:B" }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ prompt: "third", originatingChannel: "discord", originatingTo: "channel:C" }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ prompt: "fourth", originatingChannel: "discord", originatingTo: "channel:C" }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + + expect(calls.slice(0, 2).map((call) => call.prompt)).toEqual(["third", "fourth"]); + expect(calls[2]).toMatchObject({ + originatingChannel: "slack", + originatingTo: "channel:A", + }); + expect(calls[2]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); + expect(calls[2]?.prompt).toContain("- first"); + expect(calls[3]).toMatchObject({ + originatingChannel: "telegram", + originatingTo: "chat:B", + }); + expect(calls[3]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); + expect(calls[3]?.prompt).toContain("- second"); + }); + + it("compacts same-route runtime overflow under one summary owner", async () => { + const key = `test-collect-runtime-summary-owner-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const firstComplete = vi.fn(); + const secondComplete = vi.fn(); + const firstTransform: NonNullable = vi.fn( + (payload) => payload, + ); + const secondTransform: NonNullable = vi.fn( + (payload) => payload, + ); + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= 3) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 2, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + { + ...createRun({ + prompt: "first", + originatingChannel: "telegram", + originatingTo: "chat:A", + originatingReplyToId: "1", + }), + queuedDeliveryPayloadTransform: firstTransform, + queuedLifecycle: { onComplete: firstComplete }, + }, + settings, + ); + enqueueFollowupRun( + key, + { + ...createRun({ + prompt: "second", + originatingChannel: "telegram", + originatingTo: "chat:A", + originatingReplyToId: "2", + }), + queuedDeliveryPayloadTransform: secondTransform, + queuedLifecycle: { onComplete: secondComplete }, + }, + settings, + ); + enqueueFollowupRun( + key, + createRun({ prompt: "third", originatingChannel: "telegram", originatingTo: "chat:A" }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ prompt: "fourth", originatingChannel: "telegram", originatingTo: "chat:A" }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + + expect(calls.slice(0, 2).map((call) => call.prompt)).toEqual(["third", "fourth"]); + expect(calls[2]?.prompt).toContain("[Queue overflow] Dropped 2 messages due to cap."); + expect(calls[2]?.prompt).toContain("- first"); + expect(calls[2]?.prompt).toContain("- second"); + expect(calls[2]?.originatingReplyToId).toBe("2"); + expect(calls[2]?.queuedDeliveryPayloadTransform).toBe(secondTransform); + await vi.waitFor(() => { + expect(firstComplete).toHaveBeenCalledTimes(1); + expect(secondComplete).toHaveBeenCalledTimes(1); + }); }); it("preserves collect order when authorization changes more than once", async () => { @@ -1431,26 +1597,20 @@ describe("followup queue collect routing", () => { expect(onComplete).toHaveBeenCalledTimes(1); }); - it("completes summarized room-event lifecycle when overflow summary delivery fails", async () => { + it("does not retry a grouped overflow summary after terminal delivery failure", async () => { const key = `test-overflow-summary-lifecycle-failure-${Date.now()}`; const calls: FollowupRun[] = []; - const firstAttempt = createDeferred(); - const releaseRetry = createDeferred(); - const done = createDeferred(); + const summaryFailed = createDeferred(); const onComplete = vi.fn(); - let attempts = 0; const runFollowup = async (run: FollowupRun) => { calls.push(run); - attempts += 1; - if (attempts === 1) { - firstAttempt.resolve(); - throw new Error("transient failure"); + if (run.prompt.includes("[Queue overflow]")) { + summaryFailed.resolve(); + throw new Error("terminal failure"); } - await releaseRetry.promise; - done.resolve(); }; const settings: QueueSettings = { - mode: "followup", + mode: "collect", debounceMs: 0, cap: 1, dropPolicy: "summarize", @@ -1469,21 +1629,17 @@ describe("followup queue collect routing", () => { enqueueFollowupRun(key, createRun({ prompt: "live followup" }), settings); scheduleFollowupDrain(key, runFollowup); - await firstAttempt.promise; + await summaryFailed.promise; await new Promise((resolve) => { setTimeout(resolve, 10); }); expect(onComplete).toHaveBeenCalledTimes(1); - expect(getExistingFollowupQueue(key)?.summarySources).toHaveLength(0); - - releaseRetry.resolve(); - await done.promise; - expect(calls).toHaveLength(2); + expect(calls[0]?.prompt).toBe("live followup"); expect(calls[1]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); expect(calls[1]?.prompt).toContain("- dropped ambient"); - expect(onComplete).toHaveBeenCalledTimes(1); + expect(getExistingFollowupQueue(key)).toBeUndefined(); }); }); diff --git a/src/auto-reply/reply/queue.test-helpers.ts b/src/auto-reply/reply/queue.test-helpers.ts index 6c2d9d21f37f..f8e63464ce52 100644 --- a/src/auto-reply/reply/queue.test-helpers.ts +++ b/src/auto-reply/reply/queue.test-helpers.ts @@ -23,6 +23,8 @@ export function createQueueTestRun(params: { originatingTo?: string; originatingAccountId?: string; originatingThreadId?: string | number; + originatingReplyToId?: string; + originatingChatType?: string; currentInboundEventKind?: FollowupRun["currentInboundEventKind"]; }): FollowupRun { return { @@ -33,6 +35,8 @@ export function createQueueTestRun(params: { originatingTo: params.originatingTo, originatingAccountId: params.originatingAccountId, originatingThreadId: params.originatingThreadId, + originatingReplyToId: params.originatingReplyToId, + originatingChatType: params.originatingChatType, currentInboundEventKind: params.currentInboundEventKind, run: { agentId: "agent", diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index e6cfeee2de79..96b71a46795d 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -4,6 +4,8 @@ import { defaultRuntime } from "../../../runtime.js"; import { resolveGlobalMap } from "../../../shared/global-singleton.js"; import { buildCollectPrompt, + buildQueueSummaryLine, + buildQueueSummaryPrompt, beginQueueDrain, clearQueueSummaryState, drainCollectQueueStep, @@ -52,39 +54,29 @@ export function kickFollowupDrainIfIdle(key: string): void { type OriginRoutingMetadata = Pick< FollowupRun, - "originatingChannel" | "originatingTo" | "originatingAccountId" | "originatingThreadId" + | "originatingChannel" + | "originatingTo" + | "originatingAccountId" + | "originatingThreadId" + | "originatingReplyToId" + | "originatingChatType" >; function resolveOriginRoutingMetadata(items: FollowupRun[]): OriginRoutingMetadata { - const metadata: OriginRoutingMetadata = {}; - for (const item of items) { - if (!metadata.originatingChannel && item.originatingChannel) { - metadata.originatingChannel = item.originatingChannel; - } - if (!metadata.originatingTo && item.originatingTo) { - metadata.originatingTo = item.originatingTo; - } - if (!metadata.originatingAccountId && item.originatingAccountId) { - metadata.originatingAccountId = item.originatingAccountId; - } - // Support both number (Telegram topic) and string (Slack thread_ts) thread IDs. - if ( - metadata.originatingThreadId == null && - item.originatingThreadId != null && - item.originatingThreadId !== "" - ) { - metadata.originatingThreadId = item.originatingThreadId; - } - if ( - metadata.originatingChannel && - metadata.originatingTo && - metadata.originatingAccountId && - metadata.originatingThreadId != null - ) { - break; - } + const owner = items.find((item) => item.originatingChannel && item.originatingTo) ?? items.at(-1); + if (!owner) { + return {}; } - return metadata; + return { + ...(owner.originatingChannel ? { originatingChannel: owner.originatingChannel } : {}), + ...(owner.originatingTo ? { originatingTo: owner.originatingTo } : {}), + ...(owner.originatingAccountId ? { originatingAccountId: owner.originatingAccountId } : {}), + ...(owner.originatingThreadId != null && owner.originatingThreadId !== "" + ? { originatingThreadId: owner.originatingThreadId } + : {}), + ...(owner.originatingReplyToId ? { originatingReplyToId: owner.originatingReplyToId } : {}), + ...(owner.originatingChatType ? { originatingChatType: owner.originatingChatType } : {}), + }; } // Keep this key aligned with the fields that affect per-message authorization or @@ -137,6 +129,15 @@ function splitCollectItemsByAuthorization(items: FollowupRun[]): FollowupRun[][] return groups; } +function hasMixedSummarySourceContexts(items: FollowupRun[]): boolean { + return ( + items.length > 1 && + (hasCrossChannelItems(items, resolveCrossChannelKey) || + splitCollectItemsByAuthorization(items).length > 1 || + items.some(hasRuntimeOnlyFollowupMetadata)) + ); +} + function renderCollectItem(item: FollowupRun, idx: number): string { const senderLabel = item.run.senderName ?? item.run.senderUsername ?? item.run.senderId ?? item.run.senderE164; @@ -169,6 +170,10 @@ type FollowupRuntimeMetadata = Pick< | "abortSignal" | "deliveryCorrelations" | "queuedLifecycle" + | "queuedDeliveryPayloadTransform" + | "queuedDeliveryReplyToMode" + | "queuedDeliveryPayloadDidDeliver" + | "queuedExecutionContext" >; function hasCurrentTurnRuntimeMetadata(item: FollowupRun): boolean { @@ -184,7 +189,11 @@ function hasRuntimeOnlyFollowupMetadata(item: FollowupRun): boolean { hasCurrentTurnRuntimeMetadata(item) || item.abortSignal || item.deliveryCorrelations?.length || - item.queuedLifecycle, + item.queuedLifecycle || + item.queuedDeliveryPayloadTransform || + item.queuedDeliveryReplyToMode || + item.queuedDeliveryPayloadDidDeliver || + item.queuedExecutionContext, ); } @@ -237,11 +246,98 @@ function collectRuntimeMetadata( queuedLifecycle: singletonOwner?.queuedLifecycle ?? (items.length === 1 ? lifecycleSource?.queuedLifecycle : undefined), + queuedDeliveryPayloadTransform: + singletonOwner?.queuedDeliveryPayloadTransform ?? + (items.length === 1 ? items[0]?.queuedDeliveryPayloadTransform : undefined), + queuedDeliveryReplyToMode: + singletonOwner?.queuedDeliveryReplyToMode ?? + (items.length === 1 ? items[0]?.queuedDeliveryReplyToMode : undefined), + queuedDeliveryPayloadDidDeliver: + singletonOwner?.queuedDeliveryPayloadDidDeliver ?? + (items.length === 1 ? items[0]?.queuedDeliveryPayloadDidDeliver : undefined), + queuedExecutionContext: + singletonOwner?.queuedExecutionContext ?? + (items.length === 1 ? items[0]?.queuedExecutionContext : undefined), }; } -function collectSummaryRuntimeMetadata(items: FollowupRun[]): FollowupRuntimeMetadata { - return collectRuntimeMetadata(items, items.length === 1 ? items[0] : undefined); +function collectSummaryRuntimeMetadata( + items: FollowupRun[], +): FollowupRuntimeMetadata & OriginRoutingMetadata { + const runtimeOwner = items.findLast( + (item) => + item.queuedDeliveryPayloadTransform || + item.queuedDeliveryReplyToMode || + item.queuedDeliveryPayloadDidDeliver || + item.queuedExecutionContext, + ); + const summaryOwner = runtimeOwner ?? items.at(-1); + return { + ...resolveOriginRoutingMetadata(summaryOwner ? [summaryOwner] : []), + ...collectRuntimeMetadata(items, runtimeOwner ?? (items.length === 1 ? items[0] : undefined)), + }; +} + +type FollowupSummaryGroup = { + sources: FollowupRun[]; + summaryLines: string[]; + droppedCount: number; +}; + +function resolveFollowupSummaryGroupKey(item: FollowupRun): string { + return JSON.stringify([ + item.originatingChannel ?? "", + item.originatingTo ?? "", + item.originatingAccountId ?? "", + item.originatingThreadId ?? "", + resolveFollowupAuthorizationKey(item.run), + ]); +} + +function buildFollowupSummaryGroups(queue: { + droppedCount: number; + summaryLines: string[]; + summarySources?: FollowupRun[]; +}): FollowupSummaryGroup[] { + const groups = new Map(); + const sources = queue.summarySources ?? []; + for (const [index, source] of sources.entries()) { + const key = resolveFollowupSummaryGroupKey(source); + const group = groups.get(key) ?? { sources: [], summaryLines: [], droppedCount: 0 }; + group.sources.push(source); + group.summaryLines.push( + queue.summaryLines[index] ?? + buildQueueSummaryLine(source.summaryLine?.trim() || source.prompt.trim()), + ); + group.droppedCount += 1; + groups.set(key, group); + } + const retainedCount = sources.length; + const unretainedCount = Math.max(0, queue.droppedCount - retainedCount); + const firstGroup = groups.values().next().value; + if (firstGroup) { + firstGroup.droppedCount += unretainedCount; + } + return [...groups.values()]; +} + +function consumeFollowupSummaryGroup( + queue: { + droppedCount: number; + summaryLines: string[]; + summarySources?: FollowupRun[]; + }, + group: FollowupSummaryGroup, +): void { + for (const source of group.sources) { + const index = queue.summarySources?.indexOf(source) ?? -1; + if (index >= 0) { + queue.summarySources?.splice(index, 1); + queue.summaryLines.splice(index, 1); + } + completeFollowupRunLifecycle(source); + } + queue.droppedCount = Math.max(0, queue.droppedCount - group.droppedCount); } function clearFollowupQueueSummaryState(queue: { @@ -398,9 +494,17 @@ export function scheduleFollowupDrain( // Debug: `pnpm test src/auto-reply/reply/reply-flow.test.ts` // Check if messages span multiple channels. // If so, process individually to preserve per-message routing. + // Retained overflow sources still own their original route and auth + // context. Drain live items first when combining them would deliver a + // summary through a different owner. + const summarySources = queue.summarySources ?? []; const isCrossChannel = hasCrossChannelItems(queue.items, resolveCrossChannelKey) || - queue.items.some(hasRuntimeOnlyFollowupMetadata); + queue.items.some(hasRuntimeOnlyFollowupMetadata) || + hasMixedSummarySourceContexts(summarySources) || + summarySources.some(hasRuntimeOnlyFollowupMetadata) || + (summarySources.length > 0 && + hasCrossChannelItems([...queue.items, ...summarySources], resolveCrossChannelKey)); if (collectState.forceIndividualCollect && !isCrossChannel && queue.items.length > 1) { collectState.forceIndividualCollect = false; } @@ -419,13 +523,49 @@ export function scheduleFollowupDrain( const summaryOnlyPrompt = summaryOnly.prompt; const run = queue.lastRun; if (summaryOnlyPrompt && run) { + const summaryGroups = buildFollowupSummaryGroups(queue); + if (summaryGroups.length > 0) { + for (const group of summaryGroups) { + const owner = group.sources.at(-1); + if (!owner) { + continue; + } + const prompt = buildQueueSummaryPrompt({ + state: { + dropPolicy: "summarize", + droppedCount: group.droppedCount, + summaryLines: [...group.summaryLines], + }, + noun: "message", + }); + if (!prompt) { + continue; + } + try { + await effectiveRunFollowup({ + prompt, + run: owner.run, + enqueuedAt: Date.now(), + ...collectSummaryRuntimeMetadata(group.sources), + ...collectQueuedImages(group.sources), + }); + } catch (err) { + if (!isFollowupRunDeferredError(err)) { + consumeFollowupSummaryGroup(queue, group); + } + throw err; + } + consumeFollowupSummaryGroup(queue, group); + } + continue; + } await runWithDeferredSummaryRestore(summaryOnly.restore, async () => { await runWithSummarySourceCleanup(queue, async () => { await effectiveRunFollowup({ prompt: summaryOnlyPrompt, run, enqueuedAt: Date.now(), - ...collectSummaryRuntimeMetadata([]), + ...collectSummaryRuntimeMetadata(queue.summarySources ?? []), ...collectQueuedImages(queue.items), }); }); @@ -459,7 +599,7 @@ export function scheduleFollowupDrain( prompt: summary, run, enqueuedAt: Date.now(), - ...collectSummaryRuntimeMetadata([]), + ...collectSummaryRuntimeMetadata(queue.summarySources ?? []), }); }); }); diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 21cf2fc07fc7..4b6431825276 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -6,6 +6,7 @@ import type { SilentReplyPromptMode } from "../../../agents/system-prompt.types. import type { ChatType } from "../../../channels/chat-type.js"; import type { InboundEventKind } from "../../../channels/inbound-event/kind.js"; import type { SessionEntry } from "../../../config/sessions.js"; +import type { ReplyToMode } from "../../../config/types.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js"; import type { InputProvenance } from "../../../sessions/input-provenance.js"; @@ -16,6 +17,7 @@ import type { QueuedReplyLifecycle, SourceReplyDeliveryMode, } from "../../get-reply-options.types.js"; +import type { ReplyPayload } from "../../reply-payload.js"; import type { OriginatingChannelType } from "../../templating.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js"; @@ -58,6 +60,10 @@ export type FollowupRun = { abortSignal?: AbortSignal; deliveryCorrelations?: QueuedReplyDeliveryCorrelation[]; queuedLifecycle?: QueuedReplyLifecycle; + queuedDeliveryPayloadTransform?: (payload: ReplyPayload) => ReplyPayload; + queuedDeliveryReplyToMode?: ReplyToMode; + queuedDeliveryPayloadDidDeliver?: (payload: ReplyPayload) => void; + queuedExecutionContext?: (run: () => Promise) => Promise; /** Provider message ID, when available (for deduplication). */ messageId?: string; summaryLine?: string; diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 3499e6af603a..5eddc725a793 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -164,6 +164,7 @@ async function expectSlackNoDelivery( ...overrides, }); expect(res.ok).toBe(true); + expect(res.delivered).toBe(false); expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled(); return res; } @@ -357,6 +358,7 @@ describe("routeReply", () => { expect(res).toEqual({ ok: true, + delivered: false, suppressed: true, reason: "cancelled_by_reply_payload_sending_hook", }); @@ -371,6 +373,40 @@ describe("routeReply", () => { }); }); + it("reports visible delivery when a later batch part fails", async () => { + mocks.deliverOutboundPayloads.mockImplementationOnce( + async ({ + onPayloadDeliveryOutcome, + }: { + onPayloadDeliveryOutcome?: (outcome: unknown) => void; + }) => { + onPayloadDeliveryOutcome?.({ + index: 0, + status: "failed", + error: new Error("second chunk failed"), + sentBeforeError: true, + stage: "platform_send", + }); + return [{ channel: "telegram", messageId: "visible-1" }]; + }, + ); + + const res = await routeReply({ + payload: { text: "hello" }, + channel: "telegram", + to: "chat-1", + cfg: {} as never, + }); + + expect(res).toMatchObject({ + ok: false, + delivered: true, + partialFailure: true, + messageId: "visible-1", + }); + expect(res.error).toContain("second chunk failed"); + }); + it("suppresses routed delivery when reply payload hooks cancel", async () => { mocks.deliverOutboundPayloads.mockImplementationOnce( async ({ @@ -396,6 +432,7 @@ describe("routeReply", () => { expect(res).toEqual({ ok: true, + delivered: false, suppressed: true, reason: "cancelled_by_reply_payload_sending_hook", }); @@ -427,6 +464,7 @@ describe("routeReply", () => { expect(res).toEqual({ ok: true, + delivered: false, suppressed: true, reason: "empty_after_reply_payload_sending_hook", }); @@ -604,17 +642,19 @@ describe("routeReply", () => { expect(lastDeliveryPayload().text).toBe("BTW\nQuestion: what is 17 * 19?\n\n323"); }); - it("passes replyToId to Telegram sends", async () => { + it("passes reply targeting policy to Telegram sends", async () => { await routeReply({ payload: { text: "hi", replyToId: "123" }, channel: "telegram", to: "telegram:123", + replyToMode: "first", cfg: {} as never, }); expectLastDeliveryFields({ channel: "telegram", to: "telegram:123", replyToId: "123", + replyToMode: "first", }); }); diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 9a526ba86017..ec11cacfc227 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -13,6 +13,7 @@ import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import { getBundledChannelPlugin } from "../../channels/plugins/bundled.js"; import { getLoadedChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import { normalizeChatChannelId } from "../../channels/registry.js"; +import type { ReplyToMode } from "../../config/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; @@ -62,6 +63,8 @@ export type RouteReplyParams = { requesterSenderE164?: string; /** Thread id for replies (Telegram topic id or Matrix thread event id). */ threadId?: string | number; + /** Source reply fan-out policy for transports that split one payload. */ + replyToMode?: ReplyToMode; /** Config for provider-specific settings. */ cfg: OpenClawConfig; /** Optional abort signal for cooperative cancellation. */ @@ -81,8 +84,12 @@ export type RouteReplyParams = { export type RouteReplyResult = { /** Whether the reply was sent successfully. */ ok: boolean; + /** Whether provider-visible delivery actually occurred. */ + delivered: boolean; /** True when a hook intentionally suppressed provider delivery. */ suppressed?: boolean; + /** True when part of the payload was visible before a later send failed. */ + partialFailure?: boolean; /** Suppression reason when delivery was intentionally skipped. */ reason?: "cancelled_by_reply_payload_sending_hook" | "empty_after_reply_payload_sending_hook"; /** Optional message ID from the provider. */ @@ -102,7 +109,7 @@ export type RouteReplyResult = { export async function routeReply(params: RouteReplyParams): Promise { const { payload, channel, to, accountId, threadId, cfg, abortSignal } = params; if (shouldSuppressReasoningPayload(payload)) { - return { ok: true }; + return { ok: true, delivered: false }; } const normalizedChannel = normalizeMessageChannel(channel); const channelId = @@ -140,7 +147,7 @@ export async function routeReply(params: RouteReplyParams): Promise 0, messageId: last?.messageId }; } catch (err) { const message = formatErrorMessage(err); return { ok: false, + delivered: false, error: `Failed to route reply to ${channel}: ${message}`, }; } diff --git a/src/channels/message/send.test.ts b/src/channels/message/send.test.ts index f91f605ef9bc..675d7e2ce313 100644 --- a/src/channels/message/send.test.ts +++ b/src/channels/message/send.test.ts @@ -486,6 +486,34 @@ describe("withDurableMessageSendContext", () => { expect(onSendFailure).toHaveBeenCalledWith(error); }); + it("preserves receiptless partial delivery from best-effort adapters", async () => { + const error = new Error("later adapter chunk failed"); + deliverOutboundPayloads.mockImplementationOnce(async (params: DeliveryIntentCallbackParams) => { + params.onPayloadDeliveryOutcome?.({ + index: 0, + status: "failed", + error, + sentBeforeError: true, + stage: "platform_send", + }); + return []; + }); + + const result = await sendDurableMessageBatch({ + cfg, + channel: "telegram", + to: "chat-1", + payloads: [{ text: "long peer reply" }], + bestEffort: true, + }); + + expectBatchStatus(result, "partial_failed"); + expect(result.results).toEqual([]); + expect(result.receipt?.platformMessageIds).toEqual([]); + expect(result.error).toBe(error); + expect(result.sentBeforeError).toBe(true); + }); + it("reports best-effort partial failures with the delivered receipt prefix", async () => { const error = new Error("second payload failed"); deliverOutboundPayloads.mockImplementationOnce(async (params: DeliveryIntentCallbackParams) => { @@ -561,6 +589,37 @@ describe("withDurableMessageSendContext", () => { expect(onSendFailure).toHaveBeenCalledWith(error); }); + it("maps receiptless adapter partial delivery errors to partial_failed", async () => { + const cause = new Error("later adapter chunk failed"); + const error = new OutboundDeliveryError("later adapter chunk failed", { + cause, + sentBeforeError: true, + payloadOutcomes: [ + { + index: 0, + status: "failed", + error: cause, + sentBeforeError: true, + stage: "platform_send", + }, + ], + stage: "platform_send", + }); + deliverOutboundPayloads.mockRejectedValueOnce(error); + + const result = await sendDurableMessageBatch({ + cfg, + channel: "telegram", + to: "chat-1", + payloads: [{ text: "long peer reply" }], + }); + + expectBatchStatus(result, "partial_failed"); + expect(result.results).toEqual([]); + expect(result.receipt?.platformMessageIds).toEqual([]); + expect(result.sentBeforeError).toBe(true); + }); + it("runs the failure hook when send-context orchestration throws", async () => { const onSendFailure = vi.fn(); const error = new Error("boom"); diff --git a/src/channels/message/send.ts b/src/channels/message/send.ts index e783f3f9e915..596ba0d897ae 100644 --- a/src/channels/message/send.ts +++ b/src/channels/message/send.ts @@ -230,7 +230,7 @@ export async function withDurableMessageSendContext( }); const failedOutcome = payloadOutcomes.find((outcome) => outcome.status === "failed"); if (failedOutcome) { - if (results.length > 0) { + if (results.length > 0 || failedOutcome.sentBeforeError) { return { status: "partial_failed", results, @@ -269,7 +269,7 @@ export async function withDurableMessageSendContext( }; } catch (error: unknown) { if (isOutboundDeliveryError(error)) { - if (error.results.length > 0) { + if (error.sentBeforeError) { const receipt = createMessageReceiptFromOutboundResults({ results: error.results, threadId: params.threadId == null ? undefined : String(params.threadId), diff --git a/src/infra/outbound/deliver-types.ts b/src/infra/outbound/deliver-types.ts index e84f9b94c5ab..96c0b58225fd 100644 --- a/src/infra/outbound/deliver-types.ts +++ b/src/infra/outbound/deliver-types.ts @@ -68,6 +68,7 @@ export class OutboundDeliveryError extends Error { cause: unknown; results?: readonly OutboundDeliveryResult[]; payloadOutcomes?: readonly OutboundPayloadDeliveryOutcome[]; + sentBeforeError?: boolean; stage?: OutboundDeliveryFailureStage; }, ) { @@ -75,7 +76,7 @@ export class OutboundDeliveryError extends Error { this.name = "OutboundDeliveryError"; this.results = [...(options.results ?? [])]; this.payloadOutcomes = [...(options.payloadOutcomes ?? [])]; - this.sentBeforeError = this.results.length > 0; + this.sentBeforeError = this.results.length > 0 || options.sentBeforeError === true; this.stage = options.stage ?? "unknown"; } } diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 4d58a11c24da..dcc8072e2227 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -1826,6 +1826,43 @@ describe("deliverOutboundPayloads", () => { }); }); + it("preserves adapter partial-delivery metadata without a message receipt", async () => { + const adapterError = Object.assign(new Error("later adapter chunk failed"), { + sentBeforeError: true, + visibleReplySent: true, + }); + const sendPayload = vi.fn().mockRejectedValue(adapterError); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ + id: "matrix", + outbound: { + deliveryMode: "direct", + sendText: vi.fn(), + sendMedia: vi.fn(), + sendPayload, + }, + }), + }, + ]), + ); + + await expect( + deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room", + payloads: [{ text: "long peer reply", channelData: { matrix: { mode: "notice" } } }], + }), + ).rejects.toMatchObject({ + sentBeforeError: true, + payloadOutcomes: [expect.objectContaining({ status: "failed", sentBeforeError: true })], + }); + }); + it("strips internal runtime scaffolding copied into rendered and normalized nested payloads", async () => { const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix" as const, diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 1c1b9499c715..0c93801ca9d6 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -1208,6 +1208,11 @@ function toOutboundDeliveryError(params: { cause: params.error, results: params.results, payloadOutcomes: params.payloadOutcomes, + sentBeforeError: + typeof params.error === "object" && + params.error !== null && + "sentBeforeError" in params.error && + params.error.sentBeforeError === true, stage: params.stage, }); } @@ -1935,7 +1940,12 @@ async function deliverOutboundPayloadsCore( index: payloadIndex, status: "failed", error: err, - sentBeforeError: results.length > 0, + sentBeforeError: + results.length > 0 || + (typeof err === "object" && + err !== null && + "sentBeforeError" in err && + err.sentBeforeError === true), stage: "platform_send", }); errorDeliveryDiagnostics(err); diff --git a/src/infra/outbound/reply-policy.test.ts b/src/infra/outbound/reply-policy.test.ts index 623aa0e1fc73..a9ac16be0f19 100644 --- a/src/infra/outbound/reply-policy.test.ts +++ b/src/infra/outbound/reply-policy.test.ts @@ -1,7 +1,7 @@ // Covers reply-to fanout and delivery policy consumption for explicit, // implicit, single-use, and disabled reply modes. import { describe, expect, it } from "vitest"; -import { createReplyToFanout } from "./reply-policy.js"; +import { createReplyToDeliveryPolicy, createReplyToFanout } from "./reply-policy.js"; describe("createReplyToFanout", () => { it("consumes implicit single-use replies once", () => { @@ -34,3 +34,18 @@ describe("createReplyToFanout", () => { expect([next(), next()]).toEqual(["reply-1", "reply-1"]); }); }); + +describe("createReplyToDeliveryPolicy", () => { + it("consumes payload-carried implicit reply ids in single-use modes", () => { + const policy = createReplyToDeliveryPolicy({ replyToMode: "first" }); + const payload = { text: "reply", replyToId: "reply-1", replyToIdSource: "implicit" } as const; + + const first = policy.resolveCurrentReplyTo(payload); + expect(policy.applyReplyToConsumption(first, { consumeImplicitReply: true })).toEqual(first); + const second = policy.resolveCurrentReplyTo(payload); + expect(policy.applyReplyToConsumption(second, { consumeImplicitReply: true })).toEqual({ + ...second, + replyToId: undefined, + }); + }); +}); diff --git a/src/infra/outbound/reply-policy.ts b/src/infra/outbound/reply-policy.ts index 9976c28059fb..588b4ff64e42 100644 --- a/src/infra/outbound/reply-policy.ts +++ b/src/infra/outbound/reply-policy.ts @@ -57,7 +57,9 @@ export function createReplyToDeliveryPolicy(params: { const resolveCurrentReplyTo = (payload: ReplyPayload): ReplyToResolution => { if (payload.replyToId != null) { - return payload.replyToId ? { replyToId: payload.replyToId, source: "explicit" } : {}; + return payload.replyToId + ? { replyToId: payload.replyToId, source: payload.replyToIdSource ?? "explicit" } + : {}; } const replyToId = (params.replyToMode === "off" ? undefined : params.replyToId) ?? undefined; if (!replyToId) {