From c9c4072f4c5769a28cb4cd95755d64d592cdd2d3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 13 Jul 2026 13:00:03 -0700 Subject: [PATCH] fix(telegram): restore bot-to-bot LTS release probe (#106755) * docs: clarify Codex worktree invocations * fix(telegram): deliver peer-bot replies visibly * fix(telegram): harden deferred cleanup * fix(ci): align LTS Telegram checks (cherry picked from commit b68060a7cb97f560fcfb99e45022833bcbedfa35) --- AGENTS.md | 11 + docs/channels/bot-loop-protection.md | 7 +- docs/channels/telegram.md | 5 + .../telegram/src/action-runtime.test.ts | 433 +++- extensions/telegram/src/action-runtime.ts | 99 +- extensions/telegram/src/bot-core.ts | 4 + .../telegram/src/bot-handlers.runtime.ts | 943 ++++++-- ...ot-message-context.require-mention.test.ts | 40 + .../telegram/src/bot-message-context.ts | 30 +- .../telegram/src/bot-message-context.types.ts | 2 + .../telegram/src/bot-message-dispatch.ts | 1435 ++++++------ extensions/telegram/src/bot-message.ts | 2 +- ...ot-native-commands.fixture-test-support.ts | 1 + .../telegram/src/bot-native-commands.test.ts | 365 +++ .../telegram/src/bot-native-commands.ts | 413 +++- .../bot.create-telegram-bot.test-harness.ts | 1 + .../src/bot.create-telegram-bot.test.ts | 1964 +++++++++++++++++ extensions/telegram/src/bot.types.ts | 1 + .../telegram/src/bot/delivery.replies.ts | 338 ++- extensions/telegram/src/bot/delivery.send.ts | 5 +- .../telegram/src/deferred-admission.test.ts | 64 + extensions/telegram/src/deferred-admission.ts | 54 + extensions/telegram/src/delivery-error.ts | 19 + .../telegram/src/outbound-adapter.test.ts | 49 + extensions/telegram/src/outbound-adapter.ts | 43 +- .../telegram/src/peer-bot-admission.test.ts | 13 + extensions/telegram/src/peer-bot-admission.ts | 66 + extensions/telegram/src/peer-bot-loop.ts | 27 + extensions/telegram/src/peer-bot-turn.ts | 24 + extensions/telegram/src/send.ts | 124 +- extensions/telegram/src/standard-text.ts | 207 ++ src/agents/runtime-plan/types.ts | 1 + src/auto-reply/get-reply-options.types.ts | 11 +- src/auto-reply/reply-payload.ts | 2 + .../reply/commands-private-route.ts | 4 +- .../reply/dispatch-acp-delivery.test.ts | 41 +- src/auto-reply/reply/dispatch-acp-delivery.ts | 13 +- src/auto-reply/reply/dispatch-acp.test.ts | 47 +- src/auto-reply/reply/dispatch-acp.ts | 4 +- ...ispatch-from-config.reply-dispatch.test.ts | 8 +- ...ispatch-from-config.shared.test-harness.ts | 7 +- ...ispatch-from-config.stale-recovery.test.ts | 2 +- src/auto-reply/reply/dispatch-from-config.ts | 20 +- src/auto-reply/reply/followup-runner.test.ts | 162 +- src/auto-reply/reply/followup-runner.ts | 62 +- .../reply/get-reply-run.media-only.test.ts | 5 +- src/auto-reply/reply/get-reply-run.ts | 4 + src/auto-reply/reply/queue/drain.ts | 43 +- src/auto-reply/reply/queue/types.ts | 5 + src/auto-reply/reply/route-reply.test.ts | 42 +- src/auto-reply/reply/route-reply.ts | 35 +- src/channels/message/send.test.ts | 59 + src/channels/message/send.ts | 4 +- src/infra/outbound/deliver-types.ts | 3 +- src/infra/outbound/deliver.test.ts | 37 + src/infra/outbound/deliver.ts | 12 +- src/infra/outbound/reply-policy.test.ts | 17 +- src/infra/outbound/reply-policy.ts | 4 +- 58 files changed, 6419 insertions(+), 1024 deletions(-) create mode 100644 extensions/telegram/src/deferred-admission.test.ts create mode 100644 extensions/telegram/src/deferred-admission.ts create mode 100644 extensions/telegram/src/delivery-error.ts create mode 100644 extensions/telegram/src/peer-bot-admission.test.ts create mode 100644 extensions/telegram/src/peer-bot-admission.ts create mode 100644 extensions/telegram/src/peer-bot-loop.ts create mode 100644 extensions/telegram/src/peer-bot-turn.ts create mode 100644 extensions/telegram/src/standard-text.ts diff --git a/AGENTS.md b/AGENTS.md index 228a4538289c..9d8a6d74065d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,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`). @@ -132,6 +139,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. @@ -234,6 +243,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 5f9a262fd652..3efefcef0684 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -316,10 +316,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 d1c90f294c85..a039f8f45c01 100644 --- a/extensions/telegram/src/bot-core.ts +++ b/extensions/telegram/src/bot-core.ts @@ -51,6 +51,7 @@ import { import { resolveTelegramTransport } from "./fetch.js"; import { resolveTelegramScopedGroupConfig } from "./group-config-helpers.js"; import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.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"; @@ -389,6 +390,7 @@ export function createTelegramBotCore( opts, telegramDeps, }); + const peerBotAdmission = createTelegramPeerBotAdmissionCoordinator(); registerTelegramNativeCommands({ bot, @@ -410,6 +412,7 @@ export function createTelegramBotCore( shouldSkipUpdate, opts, telegramDeps, + peerBotAdmission, }); registerTelegramHandlers({ @@ -431,6 +434,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 4e4bc7035304..a22888ddc67f 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -34,8 +34,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 { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime"; import { @@ -45,7 +48,11 @@ import { } from "openclaw/plugin-sdk/session-store-runtime"; import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js"; -import { resolveTelegramAccount, resolveTelegramMediaRuntimeOptions } from "./accounts.js"; +import { + resolveDefaultTelegramAccountId, + resolveTelegramAccount, + resolveTelegramMediaRuntimeOptions, +} from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { normalizeDmAllowFromWithStore, @@ -114,6 +121,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 { @@ -165,9 +177,21 @@ import { isTelegramEditTargetMissingError, isTelegramMessageHasNoTextError, } from "./network-errors.js"; +import { + buildTelegramPeerBotAdmissionKey, + createTelegramPeerBotAdmissionCoordinator, +} from "./peer-bot-admission.js"; +import { shouldSuppressTelegramPeerBotTurn } from "./peer-bot-loop.js"; import { resolveTelegramPromptMediaPath } from "./prompt-media-path.js"; import { buildInlineKeyboard } from "./send.js"; import { buildTelegramSessionTranscriptPromptMessages } from "./session-transcript-context.js"; +import { + resolveTelegramStandardFragmentFrame, + stripTelegramStandardFragmentMarker, + TELEGRAM_STANDARD_FRAGMENT_ADMISSION_FLOOR, + TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS, + TELEGRAM_STANDARD_FRAGMENT_MAX_WIRE_CHARS, +} from "./standard-text.js"; type TelegramPromptContextMessageForDedupe = { body?: unknown; @@ -203,6 +227,7 @@ export const registerTelegramHandlers = ({ processMessage, logger, telegramDeps, + peerBotAdmission = createTelegramPeerBotAdmissionCoordinator(), resolveGroupActivation, resolveGroupRequireMention, }: RegisterTelegramHandlerParams) => { @@ -213,15 +238,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) @@ -232,6 +266,10 @@ export const registerTelegramHandlers = ({ : MEDIA_GROUP_TIMEOUT_MS; type BufferedMediaGroupEntry = MediaGroupEntry & { + afterAdmissionCallbacks: Array<{ + messageId: number; + callback: TelegramDeferredAdmissionCallback; + }>; storeAllowFrom: string[]; isGroup: boolean; isForum: boolean; @@ -244,6 +282,9 @@ export const registerTelegramHandlers = ({ topicConfig?: TelegramTopicConfig; dispatchDedupeKeys: string[]; spooledReplayParticipants: TelegramSpooledReplayDeferredParticipant[]; + cancelPeerBotAdmission?: () => void; + peerBotAdmissionCanceled?: boolean; + peerBotCancellationCleanupComplete?: boolean; }; const mediaGroupBuffer = new Map(); @@ -261,13 +302,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>, @@ -287,6 +353,7 @@ export const registerTelegramHandlers = ({ const FORWARD_BURST_DEBOUNCE_MS = 80; type TelegramDebounceLane = "default" | "forward"; type TelegramDebounceEntry = { + afterAdmissionShouldDrop?: TelegramDeferredAdmissionCallback; ctx: TelegramContext; msg: Message; allMedia: TelegramMediaRef[]; @@ -374,6 +441,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 => @@ -584,8 +662,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), }, @@ -600,6 +680,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; } @@ -615,15 +700,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), }, @@ -649,7 +748,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; @@ -679,6 +792,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)}`), + ); + }, + ); }, }); @@ -694,6 +819,9 @@ export const registerTelegramHandlers = ({ }): { agentId: string; sessionEntry: ReturnType; + bindingMode: ReturnType["bindingMode"]; + route: ReturnType["route"]; + runtimeCfg: OpenClawConfig; sessionKey: string; storePath: string; model?: string; @@ -708,7 +836,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, @@ -742,6 +870,7 @@ export const registerTelegramHandlers = ({ ({ sessionKey: key, entry: value }) => [key, value], ), ); + const routeState = { bindingMode, route, runtimeCfg }; const storedOverride = resolveStoredModelOverride({ sessionEntry: entry, sessionStore: store, @@ -753,6 +882,7 @@ export const registerTelegramHandlers = ({ }); if (storedOverride) { return { + ...routeState, agentId: route.agentId, sessionEntry: entry, sessionKey, @@ -766,6 +896,7 @@ export const registerTelegramHandlers = ({ const model = entry?.model?.trim(); if (provider && model) { return { + ...routeState, agentId: route.agentId, sessionEntry: entry, sessionKey, @@ -775,6 +906,7 @@ export const registerTelegramHandlers = ({ } const modelCfg = runtimeCfg.agents?.defaults?.model; return { + ...routeState, agentId: route.agentId, sessionEntry: entry, sessionKey, @@ -792,7 +924,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; @@ -805,7 +938,7 @@ export const registerTelegramHandlers = ({ effectiveDmAllow: NormalizedAllowFrom; groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig; - }): Promise => { + }): Promise => { const { ctx, msg, @@ -820,10 +953,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, @@ -847,7 +979,7 @@ export const registerTelegramHandlers = ({ resolveGroupRequireMention(chatId), ); if (!requireMention) { - return false; + return "accepted"; } const botUsername = ctx.me?.username?.trim().toLowerCase(); @@ -909,15 +1041,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); @@ -928,26 +1086,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; @@ -980,7 +1175,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({ @@ -1006,6 +1206,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), }, @@ -1013,53 +1217,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), }, @@ -1067,12 +1342,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); } }; @@ -1082,7 +1365,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); }; @@ -1091,7 +1391,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) => { @@ -1245,7 +1545,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 sessionBeforeTimestampMs = options?.receivedAtMs ?? (msg.date ? msg.date * 1000 : undefined); const isSessionBoundaryMessage = isTelegramSessionBoundaryCommandText( @@ -1988,8 +2290,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, @@ -2008,9 +2315,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; @@ -2043,26 +2356,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( @@ -2070,17 +2404,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, @@ -2089,82 +2436,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, @@ -2176,22 +2633,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, @@ -2204,8 +2677,9 @@ export const registerTelegramHandlers = ({ effectiveDmAllow, groupConfig, topicConfig, - }) + })) === "skipped" ) { + await effectiveAfterAdmissionShouldDrop?.(false); releaseDispatchDedupeKeys(dispatchDedupeKeys); return; } @@ -2219,7 +2693,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", @@ -2238,17 +2714,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; } @@ -2258,6 +2738,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; } @@ -2305,15 +2786,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}`, ); } @@ -3291,6 +3775,7 @@ export const registerTelegramHandlers = ({ const handleInboundMessageLike = async (event: InboundTelegramEvent) => { let dispatchDedupeKeys: string[] = []; + let pendingPeerBotAdmission: TelegramDeferredAdmissionCallback | undefined; try { if (shouldSkipUpdate(event.ctxForDedupe)) { return; @@ -3320,17 +3805,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); @@ -3338,8 +3825,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, @@ -3356,11 +4010,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({ @@ -3387,6 +4084,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, @@ -3397,11 +4099,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 292ce7070f19..28dc04270e6f 100644 --- a/extensions/telegram/src/bot-message-context.require-mention.test.ts +++ b/extensions/telegram/src/bot-message-context.require-mention.test.ts @@ -224,8 +224,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: () => ({ @@ -235,6 +237,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 19a6ed99b027..b684257d2317 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -152,6 +152,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) : ""; @@ -275,7 +287,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({ @@ -310,27 +322,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 () => { @@ -374,7 +386,7 @@ export const buildTelegramMessageContext = async ({ upsertPairingRequest, })) ) { - return null; + return await dropBeforeAdmission(); } let initialTypingCueSent = false; const ensureConfiguredBindingReady = async (): Promise => { @@ -484,7 +496,7 @@ export const buildTelegramMessageContext = async ({ logger, }); if (!bodyResult) { - return null; + return await dropBeforeAdmission(); } const groupHistoryContextMode = isGroup @@ -495,9 +507,11 @@ export const buildTelegramMessageContext = async ({ : undefined; 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.ts b/extensions/telegram/src/bot-message-dispatch.ts index a4d64940af6a..53350bbde879 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -45,6 +45,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 { @@ -97,6 +98,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, type TelegramDraftPreview } from "./draft-stream.js"; import { @@ -118,6 +120,7 @@ import { } from "./lane-delivery.js"; import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js"; import { recordOutboundMessageForPromptContext } from "./outbound-message-context.js"; +import { runWithTelegramPeerBotTurn, type TelegramPeerBotTurn } from "./peer-bot-turn.js"; import { createTelegramReasoningStepState, splitTelegramReasoningText, @@ -231,7 +234,7 @@ type DispatchTelegramMessageParams = { textLimit: number; telegramCfg: TelegramAccountConfig; telegramDeps?: TelegramBotDeps; - opts: Pick; + opts: Pick; retryDispatchErrors?: boolean; suppressFailureFallback?: boolean; }; @@ -918,8 +921,14 @@ export const dispatchTelegramMessage = async ({ ? resolveTelegramReplyId(ctxPayload.ReplyToId) : undefined; const replyQuoteTargetsBotMessage = msg.reply_to_message?.from?.is_bot === true; + 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, @@ -951,18 +960,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" ? replyQuoteTargetsBotMessage ? msg.message_id : (replyQuoteMessageId ?? msg.message_id) @@ -1391,15 +1401,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); @@ -1497,12 +1509,14 @@ export const dispatchTelegramMessage = async ({ bot, mediaLocalRoots, mediaMaxBytes: (opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 100) * 1024 * 1024, - replyToMode, + replyToMode: effectiveReplyToMode, textLimit, thread: threadSpec, tableMode, chunkMode, richMessages: telegramCfg.richMessages, + // Peer bots only observe standard Bot API messages, not rich draft updates. + standardMessages: inboundFromBot, linkPreview: telegramCfg.linkPreview, replyQuoteMessageId, replyQuoteText, @@ -1529,8 +1543,27 @@ export const dispatchTelegramMessage = async ({ let skippedDuplicateAnswerBlockDraftDelivery = false; let suppressSilentReplyFallback = false; let hadErrorReplyFailureOrSkip = false; + let terminalReplyVisible = 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; @@ -1628,6 +1661,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) { @@ -1638,9 +1684,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, @@ -1649,8 +1695,8 @@ export const dispatchTelegramMessage = async ({ }, silent, requiredCapabilities: deriveDurableFinalDeliveryRequirements({ - payload: deliverablePayload, - replyToId: deliverablePayload.replyToId, + payload: transportPayload, + replyToId: transportPayload.replyToId, threadId: threadSpec.id, silent, payloadTransport: true, @@ -1660,26 +1706,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; }; @@ -1912,350 +1981,416 @@ 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 normalizedPayload = normalizeDeliveryPayload(payload); - if (!normalizedPayload) { - return; - } - const deduped = - info.kind === "final" - ? deduplicateBlockSentMedia(normalizedPayload, sentBlockMediaUrls) - : normalizedPayload; - if (deduped === undefined) { - return; - } - const effectivePayload = deduped; + const normalizedPayload = normalizeDeliveryPayload(payload); + if (!normalizedPayload) { + return; + } + const deduped = + info.kind === "final" + ? deduplicateBlockSentMedia(normalizedPayload, sentBlockMediaUrls) + : normalizedPayload; + if (deduped === undefined) { + return; + } + const effectivePayload = deduped; - if ( - shouldSuppressLocalTelegramExecApprovalPrompt({ - cfg, - accountId: route.accountId, - payload: effectivePayload, - }) - ) { - queuedFinal = true; - return; - } - const telegramButtons = resolvePayloadTelegramInlineButtons(effectivePayload); - const lanePayload = - info.kind === "block" && - typeof payload.text === "string" && - typeof effectivePayload.text === "string" && - payload.text !== effectivePayload.text && - payload.text.trimEnd() === effectivePayload.text && - !effectivePayload.mediaUrl && - !effectivePayload.mediaUrls?.length - ? { ...effectivePayload, text: payload.text } - : effectivePayload; - const split = splitTextIntoLaneSegments( - { text: lanePayload.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(payload) && finalAnswerDelivered; - if ( - (isToolPayloadAfterFinal || isNonTerminalWarningAfterDeliveredFinal) && - !reply.hasMedia && - !hasExecApprovalPayload(effectivePayload) - ) { - return; - } - if (payload.isError === true) { - hadErrorReplyFailureOrSkip = true; - } + if ( + shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg, + accountId: route.accountId, + payload: effectivePayload, + }) + ) { + queuedFinal = true; + return; + } + const telegramButtons = resolvePayloadTelegramInlineButtons(effectivePayload); + const lanePayload = + info.kind === "block" && + typeof payload.text === "string" && + typeof effectivePayload.text === "string" && + payload.text !== effectivePayload.text && + payload.text.trimEnd() === effectivePayload.text && + !effectivePayload.mediaUrl && + !effectivePayload.mediaUrls?.length + ? { ...effectivePayload, text: payload.text } + : effectivePayload; + const split = splitTextIntoLaneSegments( + { text: lanePayload.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(payload) && 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; + } + const canRepresentAsTransientProgress = + !reply.hasMedia && + telegramButtons === undefined && + !hasExecApprovalPayload(effectivePayload); + const isFastModeProgressPayload = + isFastModeAutoProgressPayload(effectivePayload); + if (streamMode === "progress") { + if ( + canRepresentAsTransientProgress && + answerLane.stream && + !isFastModeProgressPayload + ) { + // 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 ( + (canRepresentAsTransientProgress || isFastModeProgressPayload) && + (await pushStreamToolProgress(segment.update.text, { + startImmediately: true, + })) + ) { + blockDelivered = true; + 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, + lanePayload, + 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( + lanePayload, + 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: lanePayload, + 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 = lanePayload; + lastAnswerBlockText = segment.update.text; + lastAnswerBlockButtons = telegramButtons; + } + blockDelivered = blockDelivered || result.kind !== "skipped"; + if (segment.lane === "reasoning") { + if (result.kind !== "skipped") { + reasoningStepState.noteReasoningDelivered(); + await flushBufferedFinalAnswer(); } continue; } - const canRepresentAsTransientProgress = - !reply.hasMedia && - telegramButtons === undefined && - !hasExecApprovalPayload(effectivePayload); - const isFastModeProgressPayload = - isFastModeAutoProgressPayload(effectivePayload); - if (streamMode === "progress") { - if ( - canRepresentAsTransientProgress && - answerLane.stream && - !isFastModeProgressPayload - ) { - // 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 ( - (canRepresentAsTransientProgress || isFastModeProgressPayload) && - (await pushStreamToolProgress(segment.update.text, { - startImmediately: true, - })) - ) { - blockDelivered = true; - continue; - } - } - await prepareAnswerLaneForToolProgress(); - } - - const ownedByQueuedAnswerBlockRotation = queuedAnswerBlockRotations.some( - (entry) => - queuedAnswerBlockRotationMatchesDelivery( - entry, - lanePayload, - 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( - lanePayload, - 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: lanePayload, - 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 = lanePayload; - 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 (info.kind === "final") { - reasoningStepState.resetForNextStep(); - } - } - const trackBlockMedia = (delivered: boolean) => { - if ( - delivered && - info.kind === "block" && - effectivePayload.mediaUrls?.length - ) { - for (const url of effectivePayload.mediaUrls) { - sentBlockMediaUrls.add(url); - } - } - }; - - if (segments.length > 0) { - trackBlockMedia(blockDelivered); - return; - } - if (split.suppressedReasoningOnly) { - let delivered = false; - if (reply.hasMedia) { if (info.kind === "final") { - await rotateAnswerLaneAfterToolProgress(); - await answerLane.stream?.stop(); - await reasoningLane.stream?.stop(); reasoningStepState.resetForNextStep(); } - const payloadWithoutSuppressedReasoning = - typeof effectivePayload.text === "string" - ? { ...effectivePayload, text: "" } - : effectivePayload; - delivered = await sendPayload(payloadWithoutSuppressedReasoning, { - durable: info.kind === "final", - }); } + const trackBlockMedia = (delivered: boolean) => { + if ( + delivered && + info.kind === "block" && + effectivePayload.mediaUrls?.length + ) { + for (const url of effectivePayload.mediaUrls) { + sentBlockMediaUrls.add(url); + } + } + }; + + if (segments.length > 0) { + trackBlockMedia(blockDelivered); + return; + } + if (split.suppressedReasoningOnly) { + let delivered = false; + if (reply.hasMedia) { + if (info.kind === "final") { + await rotateAnswerLaneAfterToolProgress(); + await answerLane.stream?.stop(); + await reasoningLane.stream?.stop(); + reasoningStepState.resetForNextStep(); + } + 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(); + } + 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(); + } + return; + } + const delivered = await sendPayload(effectivePayload, { + durable: info.kind === "final", + }); if (info.kind === "final" && delivered) { markProgressFinalDelivered(); } @@ -2263,314 +2398,305 @@ 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), - forceToolResultProgress: streamMode === "progress" && streamToolProgressEnabled, - 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( - buildChannelProgressDraftLineForEntry( - telegramCfg, - { - event: "tool", - itemId: payload.itemId, - toolCallId: payload.toolCallId, - 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, + 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 || 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), + forceToolResultProgress: streamMode === "progress" && streamToolProgressEnabled, + 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( + buildChannelProgressDraftLineForEntry( + telegramCfg, + { + event: "tool", + itemId: payload.itemId, + toolCallId: payload.toolCallId, + 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()) { + return; + } + await progressDraft.pushCommentaryProgress(payload.progressText, { + itemId: payload.itemId, + }); + return; + } + await pushStreamToolProgress( + buildChannelProgressDraftLineForEntry(telegramCfg, { + event: "item", + itemId: payload.itemId, + toolCallId: payload.toolCallId, + 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( + buildChannelProgressDraftLine({ + event: "plan", + phase: payload.phase, + title: payload.title, + explanation: payload.explanation, + steps: payload.steps, + }), + ); + }, + onApprovalEvent: async (payload) => { + if (payload.phase !== "requested") { + return; + } + await pushStreamToolProgress( + buildChannelProgressDraftLine({ + event: "approval", + phase: payload.phase, + title: payload.title, + command: payload.command, + reason: payload.reason, + message: payload.message, + }), + ); + }, + onToolResult: async (payload) => { + const text = payload.text?.trim(); + if (!text) { + return; + } + const updatedDraft = await pushStreamToolProgress(text, { + startImmediately: true, }); - return; - } - await pushStreamToolProgress( - buildChannelProgressDraftLineForEntry(telegramCfg, { - event: "item", - itemId: payload.itemId, - toolCallId: payload.toolCallId, - 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( - buildChannelProgressDraftLine({ - event: "plan", - phase: payload.phase, - title: payload.title, - explanation: payload.explanation, - steps: payload.steps, - }), - ); - }, - onApprovalEvent: async (payload) => { - if (payload.phase !== "requested") { - return; - } - await pushStreamToolProgress( - buildChannelProgressDraftLine({ - event: "approval", - phase: payload.phase, - title: payload.title, - command: payload.command, - reason: payload.reason, - message: payload.message, - }), - ); - }, - onToolResult: async (payload) => { - const text = payload.text?.trim(); - if (!text) { - return; - } - const updatedDraft = await pushStreamToolProgress(text, { - startImmediately: true, - }); - if ( - !updatedDraft && - isFastModeAutoProgressPayload(payload) && - !canPushStreamToolProgress() - ) { - await sendPayload(payload); - } - }, - onCommandOutput: async (payload) => { - if (payload.phase !== "end") { - return; - } - await pushStreamToolProgress( - buildChannelProgressDraftLineForEntry(telegramCfg, { - event: "command-output", - itemId: payload.itemId, - toolCallId: payload.toolCallId, - 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( - buildChannelProgressDraftLine({ - event: "patch", - itemId: payload.itemId, - toolCallId: payload.toolCallId, - 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 ( + !updatedDraft && + isFastModeAutoProgressPayload(payload) && + !canPushStreamToolProgress() + ) { + await sendPayload(payload); } - : undefined, - onCompactionEnd: statusReactionController - ? async () => { - statusReactionController.cancelPending(); - await statusReactionController.setThinking(); + }, + onCommandOutput: async (payload) => { + if (payload.phase !== "end") { + return; } - : undefined, - onModelSelected, - }, - }); - }, - }), - }, - }); + await pushStreamToolProgress( + buildChannelProgressDraftLineForEntry(telegramCfg, { + event: "command-output", + itemId: payload.itemId, + toolCallId: payload.toolCallId, + 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( + buildChannelProgressDraftLine({ + event: "patch", + itemId: payload.itemId, + toolCallId: payload.toolCallId, + 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)}`)); @@ -2640,19 +2766,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 f39a260223a4..db0518b9794e 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 () => { ({ @@ -433,6 +469,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 e835ad2755a4..55d7b4f19db6 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -7,6 +7,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 { @@ -33,6 +34,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"; @@ -95,6 +97,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 { @@ -105,6 +108,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 { @@ -124,6 +133,37 @@ type TelegramNativeReplyChannelData = { pin?: boolean; }; type FastModeState = ReturnType; + +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; @@ -134,6 +174,7 @@ type TelegramCommandAuthResult = { isGroup: boolean; isForum: boolean; resolvedThreadId?: number; + admissionThreadId?: number; senderId: string; senderUsername: string; groupConfig?: TelegramGroupConfig | TelegramDirectConfig; @@ -572,6 +613,7 @@ export type RegisterTelegramHandlerParams = { lifecycle?: import("./bot-message.js").TelegramMessageProcessorLifecycle, ) => Promise; logger: ReturnType; + peerBotAdmission?: TelegramPeerBotAdmissionCoordinator; }; export function resolveTelegramNativeCommandDisableBlockStreaming( @@ -603,7 +645,8 @@ export type RegisterTelegramNativeCommandsParams = { ) => TelegramResolvedGroupConfig; shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean; telegramDeps?: TelegramNativeCommandDeps; - opts: { token: string }; + opts: Pick; + peerBotAdmission?: TelegramPeerBotAdmissionCoordinator; }; async function resolveTelegramCommandAuth(params: { @@ -612,6 +655,7 @@ async function resolveTelegramCommandAuth(params: { cfg: OpenClawConfig; accountId: string; telegramCfg: TelegramAccountConfig; + replyToMode: ReplyToMode; readChannelAllowFromStore: TelegramBotDeps["readChannelAllowFromStore"]; allowFrom?: Array; groupAllowFrom?: Array; @@ -622,6 +666,7 @@ async function resolveTelegramCommandAuth(params: { messageThreadId?: number, ) => TelegramResolvedGroupConfig; requireAuth: boolean; + shouldSuppressRejection?: () => boolean; }): Promise { const { msg, @@ -629,6 +674,7 @@ async function resolveTelegramCommandAuth(params: { cfg, accountId, telegramCfg, + replyToMode, readChannelAllowFromStore, allowFrom, groupAllowFrom, @@ -636,6 +682,7 @@ async function resolveTelegramCommandAuth(params: { resolveGroupPolicy, resolveTelegramGroupConfig, requireAuth, + shouldSuppressRejection, } = params; const { chatId, isGroup, isForum, messageThreadId, threadParams } = await resolveTelegramNativeCommandThreadContext({ msg, bot }); @@ -682,6 +729,7 @@ async function resolveTelegramCommandAuth(params: { effectiveGroupAllow, hasGroupAllowOverride, } = groupAllowContext; + const admissionThreadId = resolvedThreadId ?? dmThreadId; const effectiveDmPolicy = resolveTelegramEffectiveDmPolicy({ isGroup, groupConfig, @@ -716,9 +764,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; }; @@ -817,6 +879,7 @@ async function resolveTelegramCommandAuth(params: { isGroup, isForum, resolvedThreadId, + ...(admissionThreadId != null ? { admissionThreadId } : {}), senderId, senderUsername, groupConfig, @@ -846,7 +909,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 }) @@ -1062,7 +1183,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; @@ -1099,6 +1230,8 @@ export const registerTelegramNativeCommands = ({ chunkMode: TelegramChunkMode; linkPreview?: boolean; richMessages?: boolean; + standardMessages?: boolean; + defaultReplyToId?: string; }) => ({ cfg: params.cfg, chatId: String(params.chatId), @@ -1112,13 +1245,15 @@ 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, richMessages: params.richMessages, + standardMessages: params.standardMessages, + defaultReplyToId: params.defaultReplyToId, }); const resolveCommandTargetSessionKey = (params: { runtimeCfg: OpenClawConfig; @@ -1159,17 +1294,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, @@ -1177,10 +1317,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, @@ -1342,6 +1495,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, }), }); @@ -1360,6 +1521,7 @@ export const registerTelegramNativeCommands = ({ userId: String(senderId || chatId), targetSessionKey: sessionKey, }); + const peerBotCommand = isTelegramPeerBotMessage(msg); const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ cfg: executionCfg, chatId, @@ -1374,6 +1536,8 @@ export const registerTelegramNativeCommands = ({ chunkMode, linkPreview: runtimeTelegramCfg.linkPreview, richMessages: runtimeTelegramCfg.richMessages, + standardMessages: peerBotCommand, + defaultReplyToId: undefined, }); let topicName: string | undefined; if (isForum && resolvedThreadId != null) { @@ -1439,10 +1603,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, }; @@ -1454,60 +1620,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); + } } }); } @@ -1518,22 +1766,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; } @@ -1543,6 +1809,7 @@ export const registerTelegramNativeCommands = ({ cfg: runtimeCfg, accountId, telegramCfg: runtimeTelegramCfg, + replyToMode: peerBotReplyToMode, readChannelAllowFromStore: telegramDeps.readChannelAllowFromStore, allowFrom, groupAllowFrom, @@ -1550,10 +1817,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({ @@ -1597,6 +1877,11 @@ export const registerTelegramNativeCommands = ({ chunkMode, linkPreview: runtimeTelegramCfg.linkPreview, richMessages: runtimeTelegramCfg.richMessages, + 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}`; @@ -1605,7 +1890,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", @@ -1672,9 +1959,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 95eac33e6ba6..80eca332ed7d 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 73662e3fbd8d..e35ddf65ce1f 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" }); @@ -4424,6 +6047,7 @@ describe("createTelegramBot", () => { replySpy.mockResolvedValue({ text: "a".repeat(TELEGRAM_RICH_TEXT_LIMIT + 1024), replyToId: String(messageId), + replyToIdSource: "implicit", }); loadConfig.mockReturnValue({ channels: { @@ -4579,6 +6203,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 c0f6f32e714f..ff52d5680cb9 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -31,6 +31,7 @@ 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 { markdownToTelegramChunks, markdownToTelegramHtml, @@ -40,6 +41,10 @@ import { import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js"; import { splitTelegramRichMessageTextChunks, TELEGRAM_RICH_TEXT_LIMIT } from "../rich-message.js"; import { buildInlineKeyboard, reactMessageTelegram } from "../send.js"; +import { + buildTelegramStandardFragmentAbort, + buildTelegramStandardTextChunks, +} from "../standard-text.js"; import { resolveTelegramVoiceSend } from "../voice.js"; import { buildTelegramSendParams, @@ -82,7 +87,7 @@ type TelegramReplyQuoteForSend = { type TelegramDeliveryTextChunk = { text: string; plainText: string; - textMode: "html"; + textMode: "html" | "markdown"; }; type ChunkTextFn = (markdown: string) => TelegramDeliveryTextChunk[]; @@ -93,7 +98,20 @@ function buildChunkTextResolver(params: { tableMode?: MarkdownTableMode; richMessages?: boolean; skipEntityDetection?: boolean; + 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, + }, + { textMode: chunk.htmlText ? ("html" as const) : ("markdown" as const) }, + ), + ); + } if (params.richMessages === true) { return (markdown: string) => splitTelegramRichMessageTextChunks({ @@ -145,6 +163,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; @@ -207,41 +278,67 @@ 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 } } + : {}), + richMessages: params.richMessages, + linkPreview: params.linkPreview, + tableMode: params.tableMode, + 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: chunk.textMode, - plainText: chunk.plainText, - richMessages: params.richMessages, - linkPreview: params.linkPreview, - tableMode: params.tableMode, + 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; } @@ -262,27 +359,51 @@ 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: chunk.textMode, - plainText: chunk.plainText, - richMessages: params.richMessages, - linkPreview: params.linkPreview, - tableMode: params.tableMode, - 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 } } + : {}), + richMessages: params.richMessages, + linkPreview: params.linkPreview, + tableMode: params.tableMode, + 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 { @@ -316,6 +437,8 @@ async function sendTelegramVoiceFallbackText(opts: { text: string; chunkText: ChunkTextFn; replyToId?: number; + replyToMode: ReplyToMode; + progress: DeliveryProgress; replyQuoteMessageId?: number; replyQuotePosition?: number; replyQuoteEntities?: unknown[]; @@ -329,32 +452,61 @@ 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: chunk.textMode, - plainText: chunk.plainText, - richMessages: opts.richMessages, - linkPreview: opts.linkPreview, - tableMode: opts.tableMode, - 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 } } + : {}), + richMessages: opts.richMessages, + linkPreview: opts.linkPreview, + tableMode: opts.tableMode, + 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; } @@ -525,6 +677,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, @@ -552,6 +706,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({ @@ -560,7 +715,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, richMessages: params.richMessages, tableMode: params.tableMode, @@ -749,6 +906,10 @@ export async function deliverReplies(params: { chunkMode?: ChunkMode; /** Opt into Telegram Bot API 10.1 rich text delivery. */ richMessages?: boolean; + /** 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). */ @@ -789,6 +950,7 @@ export async function deliverReplies(params: { tableMode: params.tableMode, richMessages: params.richMessages, skipEntityDetection: params.linkPreview === false, + standardMessages: params.standardMessages, }); const candidateReplies: ReplyPayload[] = []; for (const reply of params.replies) { @@ -829,8 +991,6 @@ export async function deliverReplies(params: { const telegramData = reply.channelData?.telegram as TelegramReplyChannelData | undefined; const reactionEmoji = typeof telegramData?.reaction?.emoji === "string" ? telegramData.reaction.emoji : undefined; - const replyToId = - params.replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId); if (reactionEmoji && typeof replyToId !== "number") { params.runtime.error?.(danger("Telegram reaction requires a reply target")); continue; @@ -850,6 +1010,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 = + hasExplicitReplyTarget || params.replyToMode !== "off" + ? resolveTelegramReplyId(reply.replyToId ?? params.defaultReplyToId) + : undefined; + const effectiveReplyToMode: ReplyToMode = hasExplicitReplyTarget ? "all" : params.replyToMode; const replyQuote = resolveReplyQuoteForSend({ replyToId, replyQuoteByMessageId: params.replyQuoteByMessageId, @@ -934,7 +1106,7 @@ export async function deliverReplies(params: { linkPreview: params.linkPreview, silent: params.silent, replyToId, - replyToMode: params.replyToMode, + replyToMode: effectiveReplyToMode, progress, }); } else if (mediaList.length > 0) { @@ -960,7 +1132,7 @@ export async function deliverReplies(params: { replyQuoteEntities: replyQuote.entities, replyMarkup, replyToId, - replyToMode: params.replyToMode, + replyToMode: effectiveReplyToMode, progress, }); firstDeliveredMessageId = mediaDelivery.firstDeliveredMessageId; @@ -1005,7 +1177,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 77951e6d5e8b..2622db7067af 100644 --- a/extensions/telegram/src/bot/delivery.send.ts +++ b/extensions/telegram/src/bot/delivery.send.ts @@ -105,6 +105,7 @@ export async function sendTelegramText( textMode?: "markdown" | "html"; plainText?: string; richMessages?: boolean; + standardMessage?: { plainText: string }; linkPreview?: boolean; tableMode?: MarkdownTableMode; silent?: boolean; @@ -121,7 +122,7 @@ export async function sendTelegramText( silent: opts?.silent, }); const textMode = opts?.textMode ?? "markdown"; - if (opts?.richMessages === true) { + if (opts?.richMessages === true && !opts.standardMessage) { const richMessage = buildTelegramRichMessage(text, textMode, { skipEntityDetection: opts.linkPreview === false, tableMode: opts.tableMode, @@ -147,7 +148,7 @@ export async function sendTelegramText( const linkPreviewEnabled = opts?.linkPreview ?? true; const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true }; const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); - const fallbackText = opts?.plainText ?? text; + const fallbackText = opts?.standardMessage?.plainText ?? opts?.plainText ?? text; const hasFallbackText = fallbackText.trim().length > 0; const sendPlainFallback = async () => { const res = await sendTelegramWithThreadFallback({ 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 ea3171bb5582..a579e2521175 100644 --- a/extensions/telegram/src/outbound-adapter.test.ts +++ b/extensions/telegram/src/outbound-adapter.test.ts @@ -138,6 +138,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 481e5511dd71..35a5f4e7a551 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -18,9 +18,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 { splitTelegramHtmlChunks } from "./format.js"; import { resolveTelegramInteractiveTextFallback } from "./interactive-fallback.js"; import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js"; @@ -58,6 +60,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; @@ -72,6 +76,8 @@ async function resolveTelegramSendContext(params: { tableMode?: OutboundDeliveryFormattingOptions["tableMode"]; messageThreadId?: number; replyToMessageId?: number; + replyToMode?: TelegramSendOpts["replyToMode"]; + replyToIdSource?: TelegramSendOpts["replyToIdSource"]; accountId?: string; silent?: boolean; gatewayClientScopes?: readonly string[]; @@ -85,6 +91,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, @@ -124,6 +132,7 @@ export async function sendTelegramPayloadMessages(params: { buttons?: TelegramInlineButtons; quoteText?: string; reaction?: { emoji?: unknown; replyToId?: unknown; replyToCurrent?: unknown }; + standardMessage?: boolean; } | undefined; const quoteText = @@ -147,6 +156,7 @@ export async function sendTelegramPayloadMessages(params: { const payloadOpts = { ...params.baseOpts, quoteText, + standardMessage: telegramData?.standardMessage === true, ...(params.payload.audioAsVoice === true ? { asVoice: true } : {}), }; if (reactionEmoji) { @@ -167,18 +177,47 @@ export async function sendTelegramPayloadMessages(params: { return { messageId: String(replyToMessageId), chatId: params.to }; } + 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({ text, 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.ts b/extensions/telegram/src/send.ts index 33adeeffdb82..086920edda7e 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -3,7 +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 { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts"; +import type { MarkdownTableMode, 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"; @@ -21,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, @@ -65,6 +66,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, @@ -103,6 +109,8 @@ type TelegramSendOpts = { retry?: RetryConfig; textMode?: "markdown" | "html"; tableMode?: MarkdownTableMode; + /** 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. */ @@ -111,6 +119,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) */ @@ -687,7 +699,7 @@ export async function sendMessageTelegram( }); const textMode = opts.textMode ?? "markdown"; - const useRichMessages = account.config.richMessages === true; + const useRichMessages = account.config.richMessages === true && opts.standardMessage !== true; const tableMode = opts.tableMode ?? resolveMarkdownTableMode({ @@ -702,6 +714,7 @@ export async function sendMessageTelegram( const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true }; type TelegramTextChunk = { + text: string; plainText: string; htmlText?: string; }; @@ -765,37 +778,85 @@ export async function sendMessageTelegram( 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.plainText, + ...(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.plainText, - ...(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({ @@ -814,6 +875,13 @@ export async function sendMessageTelegram( }; const buildChunkedTextPlan = (rawText: string, context: string): TelegramTextChunk[] => { + if (opts.standardMessage === true) { + return buildTelegramStandardTextChunks(rawText, { tableMode }).map((chunk) => ({ + text: chunk.plainText, + plainText: chunk.plainText, + ...(chunk.htmlText ? { htmlText: chunk.htmlText } : {}), + })); + } const htmlText = renderHtmlText(rawText); const fallbackText = textMode === "html" ? telegramHtmlToPlainTextFallback(htmlText) : rawText; let htmlChunks: string[]; @@ -825,17 +893,21 @@ export async function sendMessageTelegram( error, )}`, ); - return splitTelegramPlainTextChunks(fallbackText, 4000).map((plainText) => ({ plainText })); + return splitTelegramPlainTextChunks(fallbackText, 4000).map((plainText) => ({ + text: plainText, + plainText, + })); } const fixedPlainTextChunks = splitTelegramPlainTextChunks(fallbackText, 4000); if (fixedPlainTextChunks.length > htmlChunks.length) { logVerbose( `telegram ${context} plain-text fallback needs more chunks than HTML; sending plain text`, ); - return fixedPlainTextChunks.map((plainText) => ({ plainText })); + return fixedPlainTextChunks.map((plainText) => ({ text: plainText, plainText })); } const plainTextChunks = splitTelegramPlainTextFallback(fallbackText, htmlChunks.length, 4000); return htmlChunks.map((htmlTextLocal, index) => ({ + text: plainTextChunks[index] ?? htmlTextLocal, htmlText: htmlTextLocal, plainText: plainTextChunks[index] ?? htmlTextLocal, })); @@ -1145,8 +1217,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 d0a86ee4f654..d61108999bd8 100644 --- a/src/auto-reply/get-reply-options.types.ts +++ b/src/auto-reply/get-reply-options.types.ts @@ -1,8 +1,9 @@ +import type { FastMode } from "@openclaw/normalization-core/string-coerce"; +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"; import type { UserTurnTranscriptRecorder } from "../sessions/user-turn-transcript.types.js"; -import type { FastMode } from "@openclaw/normalization-core/string-coerce"; import type { ReplyPayload } from "./reply-payload.js"; import type { TypingController } from "./reply/typing.js"; @@ -220,6 +221,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 2c3c7f2da7ae..7888e5edace0 100644 --- a/src/auto-reply/reply-payload.ts +++ b/src/auto-reply/reply-payload.ts @@ -29,6 +29,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 833f9446c3b6..f0f83bfba552 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 })), })); @@ -173,7 +176,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(); @@ -916,6 +923,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 61b6da2f2230..cc296d41fb35 100644 --- a/src/auto-reply/reply/dispatch-acp-delivery.ts +++ b/src/auto-reply/reply/dispatch-acp-delivery.ts @@ -160,6 +160,7 @@ type AcpDispatchDeliveryState = { deliveredFinalReply: boolean; deliveredVisibleText: boolean; failedVisibleTextDelivery: boolean; + requiresVisibleTextFallback: boolean; queuedDirectVisibleTextDeliveries: number; settledDirectVisibleText: boolean; routedCounts: Record; @@ -182,6 +183,7 @@ export type AcpDispatchDeliveryCoordinator = { hasDeliveredFinalReply: () => boolean; hasDeliveredVisibleText: () => boolean; hasFailedVisibleTextDelivery: () => boolean; + requiresVisibleTextFallback: () => boolean; getRoutedCounts: () => Record; applyRoutedCounts: (counts: Record) => void; }; @@ -248,6 +250,7 @@ export function createAcpDispatchDeliveryCoordinator(params: { deliveredFinalReply: false, deliveredVisibleText: false, failedVisibleTextDelivery: false, + requiresVisibleTextFallback: false, queuedDirectVisibleTextDeliveries: 0, settledDirectVisibleText: false, routedCounts: { @@ -279,6 +282,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; @@ -455,11 +459,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") { @@ -470,6 +476,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, @@ -513,6 +522,7 @@ export function createAcpDispatchDeliveryCoordinator(params: { state.settledDirectVisibleText = false; } else if (!delivered && tracksVisibleText) { state.failedVisibleTextDelivery = true; + state.requiresVisibleTextFallback = true; } if (kind === "block" && delivered) { hasPendingDirectBlockReplyDelivery = true; @@ -532,6 +542,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 3659335bb327..e83770baf803 100644 --- a/src/auto-reply/reply/dispatch-acp.test.ts +++ b/src/auto-reply/reply/dispatch-acp.test.ts @@ -40,8 +40,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(() => ({ @@ -448,7 +453,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 }); @@ -578,7 +583,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({ @@ -599,8 +608,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(); @@ -1687,6 +1700,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 6bc82aa80692..311b71589e2d 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -267,7 +267,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, @@ -329,7 +329,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 16ccba09e3d2..00bb358e7acc 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 @@ -67,7 +67,11 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { resetReplyRunRegistry(); 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, @@ -205,7 +209,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.ts b/src/auto-reply/reply/dispatch-from-config.ts index f2d19f5fc5a3..0107b3a1aedc 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -1699,8 +1699,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). @@ -1746,7 +1754,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" @@ -2159,7 +2167,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; } @@ -2377,7 +2385,7 @@ export async function dispatchReplyFromConfig( }); } return { - queuedFinal: result.ok, + queuedFinal: isRoutedReplyHandled(result), routedFinalCount: isRoutedReplyDelivered(result) ? 1 : 0, }; } @@ -3437,7 +3445,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 69c0295699fd..d2eb71c4fa3a 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(); @@ -4114,8 +4115,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: {}, @@ -4126,9 +4279,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 03a81b636ee5..17a75331b144 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -394,7 +394,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, @@ -437,6 +442,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, @@ -445,22 +451,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, }); @@ -470,6 +497,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); @@ -490,7 +520,7 @@ export function createFollowupRunner(params: { } }; - return async (queued: FollowupRun) => { + const runQueuedFollowup = async (queued: FollowupRun) => { if (isFollowupRunAborted(queued)) { completeFollowupRunLifecycle(queued); typing.markRunComplete(); @@ -1484,4 +1514,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 595cb3921de9..569aa07704b3 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 c7c0421927e1..668c9faf5622 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -1295,6 +1295,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/drain.ts b/src/auto-reply/reply/queue/drain.ts index 748445a6df81..3da290cb1470 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -14,6 +14,8 @@ import { createUserTurnTranscriptRecorder } from "../../../sessions/user-turn-tr import { resolveGlobalMap } from "../../../shared/global-singleton.js"; import { buildCollectPrompt, + buildQueueSummaryLine, + buildQueueSummaryPrompt, beginQueueDrain, clearQueueSummaryState, drainCollectQueueStep, @@ -201,6 +203,15 @@ function splitCollectItemsByDeliveryContext(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; @@ -233,6 +244,10 @@ type FollowupRuntimeMetadata = Pick< | "abortSignal" | "deliveryCorrelations" | "queuedLifecycle" + | "queuedDeliveryPayloadTransform" + | "queuedDeliveryReplyToMode" + | "queuedDeliveryPayloadDidDeliver" + | "queuedExecutionContext" >; function hasCurrentTurnRuntimeMetadata(item: FollowupRun): boolean { @@ -248,7 +263,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, ); } @@ -301,6 +320,18 @@ 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), }; } @@ -740,9 +771,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; } diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 4bec2fe252d2..9bbaa923075d 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -19,6 +19,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"; @@ -63,6 +64,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 652f73cca910..67091964304f 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -182,6 +182,7 @@ async function expectSlackNoDelivery( ...overrides, }); expect(res.ok).toBe(true); + expect(res.delivered).toBe(false); expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled(); return res; } @@ -573,6 +574,7 @@ describe("routeReply", () => { expect(res).toEqual({ ok: true, + delivered: false, suppressed: true, reason: "cancelled_by_reply_payload_sending_hook", }); @@ -587,6 +589,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 ({ @@ -612,6 +648,7 @@ describe("routeReply", () => { expect(res).toEqual({ ok: true, + delivered: false, suppressed: true, reason: "cancelled_by_reply_payload_sending_hook", }); @@ -643,6 +680,7 @@ describe("routeReply", () => { expect(res).toEqual({ ok: true, + delivered: false, suppressed: true, reason: "empty_after_reply_payload_sending_hook", }); @@ -820,17 +858,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 db10c8e626e7..fa6979c5d903 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -14,6 +14,7 @@ import { normalizeChatType } from "../../channels/chat-type.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"; @@ -89,6 +90,8 @@ type RouteReplyParams = { threadId?: string | number; /** Reply policy fallback for delivery kinds that do not carry payload metadata. */ replyDelivery?: ReplyDeliveryContext; + /** 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. */ @@ -108,8 +111,12 @@ type RouteReplyParams = { 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. */ @@ -129,7 +136,7 @@ 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 = @@ -167,7 +174,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 600d5d9419cb..8fd7bc2f731b 100644 --- a/src/channels/message/send.ts +++ b/src/channels/message/send.ts @@ -228,7 +228,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, @@ -267,7 +267,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 9f59ce12c5ab..c95759d6ff69 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -1884,6 +1884,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 5e912098a526..a3db7c2b1d06 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, }); } @@ -1962,7 +1967,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) {