mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 00:52:10 -06:00
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
This commit is contained in:
committed by
GitHub
parent
c18e71be7e
commit
b68060a7cb
@@ -119,6 +119,13 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <path-or-filter>` 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 <remote-branch-or-tag>`; 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 <provider> --id <lease>`; no positional id or `--timing-json`.
|
||||
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
|
||||
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`.
|
||||
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`).
|
||||
@@ -131,6 +138,8 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Visual proof: use Crabbox, set up like a user, then screenshot-verify. No harness/bypass/shortcut unless explicitly asked.
|
||||
- Small/narrow tests, lints, format checks, and type probes are fine locally only in a healthy normal checkout.
|
||||
- In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
|
||||
- Codex-worktree commit after equivalent remote hook proof: `git commit --no-verify --no-gpg-sign`; do not invoke `scripts/committer`.
|
||||
- Git continuation commands run commit hooks too; pre-format and use hook-free continuation only after equivalent remote proof.
|
||||
- Full suites, broad changed gates, Docker/package/E2E/live/cross-OS proof, or anything that bogs down the Mac: Crabbox/Testbox.
|
||||
- One/few files local. If a local command fans out, stop and move broad proof to Crabbox/Testbox.
|
||||
- Before handoff/push: prove touched surface. Before landing to `main`: issue proof plus appropriate full/broad proof unless scope is clearly narrow.
|
||||
@@ -233,6 +242,8 @@ Skills own workflows; root owns hard policy and routing.
|
||||
|
||||
## Git
|
||||
|
||||
- zsh: quote optional glob patterns; unmatched globs abort commands.
|
||||
- LTS worktrees: Testbox full sync can mix main hydration with release packages; use direct Crabbox when lock/package shapes differ.
|
||||
- Commit via `scripts/committer "<msg>" <file...>`; 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -295,10 +295,15 @@ curl "https://api.telegram.org/bot<bot_token>/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).
|
||||
|
||||
<Note>
|
||||
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).
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
`channels.telegram.dm.threadReplies` and `channels.telegram.direct.<chatId>.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.
|
||||
</Note>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof normalizeTelegramDeliveryPin>;
|
||||
buttons?: ReturnType<typeof resolveTelegramButtonsFromParams>;
|
||||
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,
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
resolveTelegramOutboundClientTimeoutFloorSeconds,
|
||||
} from "./client-fetch.js";
|
||||
import { resolveTelegramTransport } from "./fetch.js";
|
||||
import { createTelegramPeerBotAdmissionCoordinator } from "./peer-bot-admission.js";
|
||||
import { stringifyTelegramRawUpdateForLog } from "./raw-update-log.js";
|
||||
import { TELEGRAM_RICH_TEXT_LIMIT } from "./rich-message.js";
|
||||
import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js";
|
||||
@@ -418,6 +419,7 @@ export function createTelegramBotCore(
|
||||
opts,
|
||||
telegramDeps,
|
||||
});
|
||||
const peerBotAdmission = createTelegramPeerBotAdmissionCoordinator();
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
bot,
|
||||
@@ -439,6 +441,7 @@ export function createTelegramBotCore(
|
||||
shouldSkipUpdate,
|
||||
opts,
|
||||
telegramDeps,
|
||||
peerBotAdmission,
|
||||
});
|
||||
|
||||
registerTelegramHandlers({
|
||||
@@ -460,6 +463,7 @@ export function createTelegramBotCore(
|
||||
processMessage,
|
||||
logger,
|
||||
telegramDeps,
|
||||
peerBotAdmission,
|
||||
});
|
||||
|
||||
const originalStop = bot.stop.bind(bot);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -173,8 +173,10 @@ describe("buildTelegramMessageContext requireMention precedence", () => {
|
||||
});
|
||||
|
||||
it("lets explicit topic requireMention=true override always activation", async () => {
|
||||
const afterAdmissionShouldDrop = vi.fn(async () => false);
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
message: buildForumMessage(),
|
||||
options: { afterAdmissionShouldDrop },
|
||||
resolveGroupActivation: () => false,
|
||||
resolveGroupRequireMention: () => false,
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
@@ -184,6 +186,44 @@ describe("buildTelegramMessageContext requireMention precedence", () => {
|
||||
});
|
||||
|
||||
expect(ctx).toBeNull();
|
||||
expect(afterAdmissionShouldDrop).toHaveBeenCalledOnce();
|
||||
expect(afterAdmissionShouldDrop).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("lets deferred admission suppress an addressed group turn", async () => {
|
||||
const afterAdmissionShouldDrop = vi.fn(async () => true);
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
message: { ...buildForumMessage(), text: "@bot hello" },
|
||||
options: { afterAdmissionShouldDrop },
|
||||
resolveGroupActivation: () => false,
|
||||
resolveGroupRequireMention: () => true,
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: { requireMention: true },
|
||||
topicConfig: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(ctx).toBeNull();
|
||||
expect(afterAdmissionShouldDrop).toHaveBeenCalledOnce();
|
||||
expect(afterAdmissionShouldDrop).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("finalizes deferred admission when group policy drops before body admission", async () => {
|
||||
const afterAdmissionShouldDrop = vi.fn(async () => false);
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
message: buildForumMessage(),
|
||||
options: { afterAdmissionShouldDrop },
|
||||
resolveGroupActivation: () => false,
|
||||
resolveGroupRequireMention: () => false,
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: { enabled: false },
|
||||
topicConfig: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(ctx).toBeNull();
|
||||
expect(afterAdmissionShouldDrop).toHaveBeenCalledOnce();
|
||||
expect(afterAdmissionShouldDrop).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("keeps activation fallback when no topic requireMention is configured", async () => {
|
||||
|
||||
@@ -147,6 +147,18 @@ export const buildTelegramMessageContext = async ({
|
||||
sendChatActionHandler,
|
||||
}: BuildTelegramMessageContextParams): Promise<TelegramMessageContext | null> => {
|
||||
const msg = primaryCtx.message;
|
||||
let admissionFinalized = false;
|
||||
const finalizeAdmission = async (admitted: boolean): Promise<boolean> => {
|
||||
if (admissionFinalized) {
|
||||
return false;
|
||||
}
|
||||
admissionFinalized = true;
|
||||
return (await options?.afterAdmissionShouldDrop?.(admitted)) ?? false;
|
||||
};
|
||||
const dropBeforeAdmission = async (): Promise<null> => {
|
||||
await finalizeAdmission(false);
|
||||
return null;
|
||||
};
|
||||
const chatId = msg.chat.id;
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||
@@ -270,7 +282,7 @@ export const buildTelegramMessageContext = async ({
|
||||
reason: "non-default account requires explicit binding",
|
||||
target: route.accountId,
|
||||
});
|
||||
return null;
|
||||
return await dropBeforeAdmission();
|
||||
}
|
||||
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
||||
const dmAllow = await resolveTelegramDmAllow({
|
||||
@@ -305,27 +317,27 @@ export const buildTelegramMessageContext = async ({
|
||||
if (!baseAccess.allowed) {
|
||||
if (baseAccess.reason === "group-disabled") {
|
||||
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
|
||||
return null;
|
||||
return await dropBeforeAdmission();
|
||||
}
|
||||
if (baseAccess.reason === "topic-disabled") {
|
||||
logVerbose(
|
||||
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
|
||||
);
|
||||
return null;
|
||||
return await dropBeforeAdmission();
|
||||
}
|
||||
logVerbose(
|
||||
isGroup
|
||||
? `Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`
|
||||
: `Blocked telegram DM sender ${senderId || "unknown"} (DM allowFrom override)`,
|
||||
);
|
||||
return null;
|
||||
return await dropBeforeAdmission();
|
||||
}
|
||||
|
||||
const requireTopic = directConfig?.requireTopic;
|
||||
const topicRequiredButMissing = !isGroup && requireTopic === true && dmThreadId == null;
|
||||
if (topicRequiredButMissing) {
|
||||
logVerbose(`Blocked telegram DM ${chatId}: requireTopic=true but no topic present`);
|
||||
return null;
|
||||
return await dropBeforeAdmission();
|
||||
}
|
||||
|
||||
const sendTyping = async () => {
|
||||
@@ -369,7 +381,7 @@ export const buildTelegramMessageContext = async ({
|
||||
upsertPairingRequest,
|
||||
}))
|
||||
) {
|
||||
return null;
|
||||
return await dropBeforeAdmission();
|
||||
}
|
||||
let initialTypingCueSent = false;
|
||||
const ensureConfiguredBindingReady = async (): Promise<boolean> => {
|
||||
@@ -479,13 +491,15 @@ export const buildTelegramMessageContext = async ({
|
||||
logger,
|
||||
});
|
||||
if (!bodyResult) {
|
||||
return null;
|
||||
return await dropBeforeAdmission();
|
||||
}
|
||||
|
||||
if (!(await ensureConfiguredBindingReady())) {
|
||||
return await dropBeforeAdmission();
|
||||
}
|
||||
if (await finalizeAdmission(true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Direct chats are now reply-eligible; send the first typing cue before
|
||||
// expensive context/session construction without showing typing for dropped turns.
|
||||
if (!isGroup) {
|
||||
|
||||
@@ -19,11 +19,13 @@ export type TelegramMediaRef = {
|
||||
};
|
||||
|
||||
export type TelegramMessageContextOptions = {
|
||||
afterAdmissionShouldDrop?: (admitted: boolean, cacheMessage?: boolean) => Promise<boolean>;
|
||||
commandSource?: "text" | "native";
|
||||
forceWasMentioned?: boolean;
|
||||
messageIdOverride?: string;
|
||||
receivedAtMs?: number;
|
||||
ingressBuffer?: "inbound-debounce" | "text-fragment";
|
||||
promptContextThreadId?: number;
|
||||
promptContextMinTimestampMs?: number;
|
||||
spooledReplay?: boolean;
|
||||
};
|
||||
|
||||
@@ -594,6 +594,288 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses observable standard delivery instead of rich drafts for bot-originated turns", async () => {
|
||||
loadSessionStore.mockReturnValue({ s1: { reasoningLevel: "stream" } });
|
||||
deliverInboundReplyWithMessageSendContext.mockResolvedValue({
|
||||
status: "handled_visible",
|
||||
delivery: { messageIds: ["rich"], visibleReplySent: true },
|
||||
});
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "Ready" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext({
|
||||
ctxPayload: {
|
||||
SessionKey: "s1",
|
||||
MessageSid: "456",
|
||||
ReplyToId: "123",
|
||||
ReplyToBody: "quoted human message",
|
||||
ReplyToQuoteText: "quoted human message",
|
||||
ReplyToIsQuote: true,
|
||||
} as unknown as TelegramMessageContext["ctxPayload"],
|
||||
msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"],
|
||||
}),
|
||||
replyToMode: "first",
|
||||
telegramCfg: { replyToMode: "first" },
|
||||
});
|
||||
|
||||
expect(createTelegramDraftStream).not.toHaveBeenCalled();
|
||||
expect(deliverInboundReplyWithMessageSendContext).toHaveBeenCalledTimes(1);
|
||||
expect(mockCallArg(deliverInboundReplyWithMessageSendContext)).toMatchObject({
|
||||
payload: {
|
||||
text: "Ready",
|
||||
replyToId: "456",
|
||||
replyToIdSource: "implicit",
|
||||
channelData: { telegram: { standardMessage: true } },
|
||||
},
|
||||
replyToMode: "first",
|
||||
});
|
||||
expect(
|
||||
dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0].replyOptions
|
||||
?.sourceReplyDeliveryMode,
|
||||
).toBeUndefined();
|
||||
expect(deliverReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults bot-originated standard delivery to explicit reply targeting", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "Ready" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext({
|
||||
ctxPayload: { MessageSid: "456" } as unknown as TelegramMessageContext["ctxPayload"],
|
||||
msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(mockCallArg(deliverInboundReplyWithMessageSendContext)).toMatchObject({
|
||||
payload: {
|
||||
text: "Ready",
|
||||
replyToId: "456",
|
||||
replyToIdSource: "implicit",
|
||||
channelData: { telegram: { standardMessage: true } },
|
||||
},
|
||||
replyToMode: "all",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit replyToMode off for bot-originated standard delivery", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "Ready" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext({
|
||||
ctxPayload: { MessageSid: "456" } as unknown as TelegramMessageContext["ctxPayload"],
|
||||
msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"],
|
||||
}),
|
||||
replyToMode: "off",
|
||||
telegramCfg: { replyToMode: "off" },
|
||||
});
|
||||
|
||||
expect(mockCallArg(deliverInboundReplyWithMessageSendContext)).toMatchObject({
|
||||
payload: {
|
||||
text: "Ready",
|
||||
channelData: { telegram: { standardMessage: true } },
|
||||
},
|
||||
replyToMode: "off",
|
||||
});
|
||||
expect(
|
||||
mockCallArg(deliverInboundReplyWithMessageSendContext).payload.replyToId,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves explicit reply targets on bot-originated standard delivery", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "Ready", replyToId: "999" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext({
|
||||
ctxPayload: { MessageSid: "456" } as unknown as TelegramMessageContext["ctxPayload"],
|
||||
msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"],
|
||||
}),
|
||||
replyToMode: "first",
|
||||
telegramCfg: { replyToMode: "first" },
|
||||
});
|
||||
|
||||
expect(mockCallArg(deliverInboundReplyWithMessageSendContext).payload).toMatchObject({
|
||||
text: "Ready",
|
||||
replyToId: "999",
|
||||
});
|
||||
});
|
||||
|
||||
it("consumes queued peer-bot implicit targets across payloads", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ queuedFinal: true });
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext({
|
||||
ctxPayload: { MessageSid: "456" } as unknown as TelegramMessageContext["ctxPayload"],
|
||||
msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"],
|
||||
}),
|
||||
replyToMode: "first",
|
||||
telegramCfg: { replyToMode: "first" },
|
||||
});
|
||||
|
||||
const transform =
|
||||
dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0].replyOptions
|
||||
?.queuedDeliveryPayloadTransform;
|
||||
const didDeliver =
|
||||
dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0].replyOptions
|
||||
?.queuedDeliveryPayloadDidDeliver;
|
||||
expect(
|
||||
dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0].replyOptions
|
||||
?.queuedDeliveryReplyToMode,
|
||||
).toBe("first");
|
||||
expect(transform).toBeTypeOf("function");
|
||||
expect(didDeliver).toBeTypeOf("function");
|
||||
const first = transform?.({ text: "first" });
|
||||
expect(first).toMatchObject({
|
||||
replyToId: "456",
|
||||
replyToIdSource: "implicit",
|
||||
});
|
||||
expect(transform?.({ text: "not-yet-delivered" })).toMatchObject({ replyToId: "456" });
|
||||
if (first) {
|
||||
didDeliver?.(first);
|
||||
}
|
||||
expect(transform?.({ text: "second" })?.replyToId).toBeUndefined();
|
||||
expect(transform?.({ text: "explicit", replyToId: "999" })?.replyToId).toBe("999");
|
||||
});
|
||||
|
||||
it("does not consume a peer reply target before visible delivery", async () => {
|
||||
deliverInboundReplyWithMessageSendContext
|
||||
.mockResolvedValueOnce({ status: "handled_no_send", reason: "no_visible_result" })
|
||||
.mockResolvedValueOnce({
|
||||
status: "handled_visible",
|
||||
delivery: { messageIds: ["visible"], visibleReplySent: true },
|
||||
});
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "suppressed" }, { kind: "final" });
|
||||
await dispatcherOptions.deliver({ text: "visible" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext({
|
||||
ctxPayload: { MessageSid: "456" } as unknown as TelegramMessageContext["ctxPayload"],
|
||||
msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"],
|
||||
}),
|
||||
replyToMode: "first",
|
||||
telegramCfg: { replyToMode: "first" },
|
||||
});
|
||||
|
||||
expect(deliverInboundReplyWithMessageSendContext).toHaveBeenCalledTimes(2);
|
||||
expect(deliverInboundReplyWithMessageSendContext.mock.calls[1]?.[0].payload).toMatchObject({
|
||||
text: "visible",
|
||||
replyToId: "456",
|
||||
replyToIdSource: "implicit",
|
||||
});
|
||||
});
|
||||
|
||||
it("consumes a peer reply target after a partially visible durable failure", async () => {
|
||||
const partialError = new Error("second chunk failed");
|
||||
deliverInboundReplyWithMessageSendContext
|
||||
.mockResolvedValueOnce({
|
||||
status: "failed",
|
||||
error: partialError,
|
||||
sentBeforeError: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: "handled_visible",
|
||||
delivery: { messageIds: ["visible"], visibleReplySent: true },
|
||||
});
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await expect(dispatcherOptions.deliver({ text: "partial" }, { kind: "final" })).rejects.toBe(
|
||||
partialError,
|
||||
);
|
||||
await dispatcherOptions.deliver({ text: "later" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext({
|
||||
ctxPayload: { MessageSid: "456" } as unknown as TelegramMessageContext["ctxPayload"],
|
||||
msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"],
|
||||
}),
|
||||
replyToMode: "first",
|
||||
telegramCfg: { replyToMode: "first" },
|
||||
});
|
||||
|
||||
expect(deliverInboundReplyWithMessageSendContext).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
deliverInboundReplyWithMessageSendContext.mock.calls[1]?.[0].payload.replyToId,
|
||||
).toBeUndefined();
|
||||
expect(deliverReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send a fallback after a partially visible durable failure", async () => {
|
||||
deliverInboundReplyWithMessageSendContext.mockResolvedValueOnce({
|
||||
status: "failed",
|
||||
error: new Error("second chunk failed"),
|
||||
sentBeforeError: true,
|
||||
});
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "partial" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext({
|
||||
ctxPayload: { MessageSid: "456" } as unknown as TelegramMessageContext["ctxPayload"],
|
||||
msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"],
|
||||
}),
|
||||
replyToMode: "first",
|
||||
telegramCfg: { replyToMode: "first" },
|
||||
});
|
||||
|
||||
expect(deliverReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send a fallback after a partially visible direct failure", async () => {
|
||||
const partialError = Object.assign(new Error("second direct chunk failed"), {
|
||||
sentBeforeError: true,
|
||||
visibleReplySent: true,
|
||||
});
|
||||
deliverReplies.mockRejectedValueOnce(partialError);
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "partial" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext({
|
||||
ctxPayload: { MessageSid: "456" } as unknown as TelegramMessageContext["ctxPayload"],
|
||||
msg: { from: { id: 99, is_bot: true } } as unknown as TelegramMessageContext["msg"],
|
||||
}),
|
||||
replyToMode: "first",
|
||||
telegramCfg: { replyToMode: "first" },
|
||||
});
|
||||
|
||||
expect(deliverReplies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends a terminal failure fallback after progress-only delivery", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "working" }, { kind: "tool" });
|
||||
throw new Error("agent failed after progress");
|
||||
});
|
||||
|
||||
await dispatchWithContext({ context: createContext() });
|
||||
|
||||
expect(deliverReplies).toHaveBeenCalledTimes(2);
|
||||
expect(deliverReplies.mock.calls[1]?.[0].replies).toEqual([
|
||||
expect.objectContaining({
|
||||
text: "Something went wrong while processing your request. Please try again.",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("recovers forum thread context from a topic-scoped session key", async () => {
|
||||
const recordInboundSession = vi.fn(async () => undefined);
|
||||
const oldHistoryKey = "-1003774691294:topic:1";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,7 +50,7 @@ type TelegramMessageProcessorDeps = Omit<
|
||||
streamMode: TelegramStreamMode;
|
||||
textLimit: number;
|
||||
telegramDeps: TelegramBotDeps;
|
||||
opts: Pick<TelegramBotOptions, "token">;
|
||||
opts: Pick<TelegramBotOptions, "token" | "replyToMode">;
|
||||
};
|
||||
|
||||
export type TelegramMessageProcessorLifecycle = {
|
||||
|
||||
@@ -59,6 +59,7 @@ export function createNativeCommandTestParams(
|
||||
shouldSkipUpdate: params.shouldSkipUpdate ?? (() => false),
|
||||
telegramDeps: params.telegramDeps,
|
||||
opts: params.opts ?? { token: "token" },
|
||||
peerBotAdmission: params.peerBotAdmission,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Array<unknown>> } });
|
||||
}
|
||||
|
||||
function deliverRepliesParamsAt(index: number) {
|
||||
const calls = (deliverReplies as unknown as { mock: { calls: Array<Array<unknown>> } }).mock
|
||||
.calls;
|
||||
const params = calls[index]?.[0];
|
||||
if (!params) {
|
||||
throw new Error(`expected deliverReplies call ${index}`);
|
||||
}
|
||||
return params as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireTelegramDeps(
|
||||
params: ReturnType<typeof createNativeCommandTestParams>,
|
||||
): 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<typeof vi.fn>;
|
||||
} {
|
||||
const cancel = vi.fn(async () => undefined);
|
||||
return {
|
||||
cancel,
|
||||
coordinator: {
|
||||
cancel,
|
||||
registerCancellation: vi.fn(() => () => undefined),
|
||||
reserve: vi.fn(() => async () => false),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerTelegramNativeCommands", () => {
|
||||
beforeAll(async () => {
|
||||
({
|
||||
@@ -412,6 +448,335 @@ describe("registerTelegramNativeCommands", () => {
|
||||
expect(parseTelegramNativeCommandCallbackData("tgcmd:fast status")).toBeNull();
|
||||
});
|
||||
|
||||
it("commits the peer reply target after partially visible native delivery", async () => {
|
||||
const { bot, commandHandlers } = createCommandBot();
|
||||
const cfg: OpenClawConfig = {
|
||||
commands: { native: true },
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
};
|
||||
const baseParams = createNativeCommandTestParams(cfg, {
|
||||
bot,
|
||||
allowFrom: ["*"],
|
||||
groupAllowFrom: ["*"],
|
||||
replyToMode: "first",
|
||||
opts: { token: "token", replyToMode: "first" },
|
||||
});
|
||||
const dispatchReplyWithBufferedBlockDispatcher: TelegramNativeCommandDeps["dispatchReplyWithBufferedBlockDispatcher"] =
|
||||
async (params) => {
|
||||
const deliver = params.dispatcherOptions.deliver;
|
||||
const info = { kind: "block" } as Parameters<typeof deliver>[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<typeof params.dispatcherOptions.deliver>[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 = {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
resolveDefaultModelForAgent,
|
||||
resolveThinkingDefaultWithRuntimeCatalog,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { recordChannelBotPairLoopAndCheckSuppression } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import {
|
||||
@@ -32,6 +33,7 @@ import type {
|
||||
} from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
|
||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
@@ -93,6 +95,7 @@ import {
|
||||
resolveTelegramConversationBaseSessionKey,
|
||||
resolveTelegramConversationRoute,
|
||||
} from "./conversation-route.js";
|
||||
import { isTelegramDeliveryErrorVisible } from "./delivery-error.js";
|
||||
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
|
||||
import type { TelegramTransport } from "./fetch.js";
|
||||
import {
|
||||
@@ -103,6 +106,12 @@ import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
|
||||
import { resolveTelegramCommandIngressAuthorization } from "./ingress.js";
|
||||
import { buildInlineKeyboard } from "./inline-keyboard.js";
|
||||
import { buildTelegramNativeCommandCallbackData } from "./native-command-callback-data.js";
|
||||
import {
|
||||
buildTelegramPeerBotAdmissionKey,
|
||||
createTelegramPeerBotAdmissionCoordinator,
|
||||
type TelegramPeerBotAdmissionCoordinator,
|
||||
} from "./peer-bot-admission.js";
|
||||
import { runWithTelegramPeerBotTurn } from "./peer-bot-turn.js";
|
||||
import { recordSentMessage } from "./sent-message-cache.js";
|
||||
import { getTopicName, resolveTopicNameCacheScope } from "./topic-name-cache.js";
|
||||
export {
|
||||
@@ -121,6 +130,37 @@ type TelegramNativeReplyChannelData = {
|
||||
buttons?: TelegramInlineButtons;
|
||||
pin?: boolean;
|
||||
};
|
||||
|
||||
function isTelegramPeerBotMessage(msg: TelegramNativeCommandContext["message"]): boolean {
|
||||
return msg?.from?.is_bot === true && msg.sender_chat == null;
|
||||
}
|
||||
|
||||
function shouldSuppressTelegramBotCommandLoop(params: {
|
||||
msg: TelegramNativeCommandContext["message"];
|
||||
botId?: number;
|
||||
accountId: string;
|
||||
cfg: OpenClawConfig;
|
||||
}): boolean {
|
||||
const msg = params.msg;
|
||||
const sender = msg?.from;
|
||||
if (
|
||||
!msg ||
|
||||
!isTelegramPeerBotMessage(msg) ||
|
||||
!sender ||
|
||||
params.botId == null ||
|
||||
sender.id === params.botId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return recordChannelBotPairLoopAndCheckSuppression({
|
||||
scopeId: params.accountId,
|
||||
conversationId: `${msg.chat.id}:${msg.message_thread_id ?? ""}`,
|
||||
senderId: String(sender.id),
|
||||
receiverId: String(params.botId),
|
||||
defaultsConfig: params.cfg.channels?.defaults?.botLoopProtection,
|
||||
defaultEnabled: true,
|
||||
}).suppressed;
|
||||
}
|
||||
type TelegramResolvedGroupConfig = {
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
@@ -131,6 +171,7 @@ type TelegramCommandAuthResult = {
|
||||
isGroup: boolean;
|
||||
isForum: boolean;
|
||||
resolvedThreadId?: number;
|
||||
admissionThreadId?: number;
|
||||
senderId: string;
|
||||
senderUsername: string;
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
@@ -447,6 +488,7 @@ export type RegisterTelegramHandlerParams = {
|
||||
lifecycle?: import("./bot-message.js").TelegramMessageProcessorLifecycle,
|
||||
) => Promise<TelegramMessageProcessingResult>;
|
||||
logger: ReturnType<typeof getChildLogger>;
|
||||
peerBotAdmission?: TelegramPeerBotAdmissionCoordinator;
|
||||
};
|
||||
|
||||
export function resolveTelegramNativeCommandDisableBlockStreaming(
|
||||
@@ -478,7 +520,8 @@ export type RegisterTelegramNativeCommandsParams = {
|
||||
) => TelegramResolvedGroupConfig;
|
||||
shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean;
|
||||
telegramDeps?: TelegramNativeCommandDeps;
|
||||
opts: { token: string };
|
||||
opts: Pick<TelegramBotOptions, "token" | "replyToMode">;
|
||||
peerBotAdmission?: TelegramPeerBotAdmissionCoordinator;
|
||||
};
|
||||
|
||||
async function resolveTelegramCommandAuth(params: {
|
||||
@@ -487,6 +530,7 @@ async function resolveTelegramCommandAuth(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
telegramCfg: TelegramAccountConfig;
|
||||
replyToMode: ReplyToMode;
|
||||
readChannelAllowFromStore: TelegramBotDeps["readChannelAllowFromStore"];
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
@@ -497,6 +541,7 @@ async function resolveTelegramCommandAuth(params: {
|
||||
messageThreadId?: number,
|
||||
) => TelegramResolvedGroupConfig;
|
||||
requireAuth: boolean;
|
||||
shouldSuppressRejection?: () => boolean;
|
||||
}): Promise<TelegramCommandAuthResult | null> {
|
||||
const {
|
||||
msg,
|
||||
@@ -504,6 +549,7 @@ async function resolveTelegramCommandAuth(params: {
|
||||
cfg,
|
||||
accountId,
|
||||
telegramCfg,
|
||||
replyToMode,
|
||||
readChannelAllowFromStore,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
@@ -511,6 +557,7 @@ async function resolveTelegramCommandAuth(params: {
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
requireAuth,
|
||||
shouldSuppressRejection,
|
||||
} = params;
|
||||
const { chatId, isGroup, isForum, messageThreadId, threadParams } =
|
||||
await resolveTelegramNativeCommandThreadContext({ msg, bot });
|
||||
@@ -557,6 +604,7 @@ async function resolveTelegramCommandAuth(params: {
|
||||
effectiveGroupAllow,
|
||||
hasGroupAllowOverride,
|
||||
} = groupAllowContext;
|
||||
const admissionThreadId = resolvedThreadId ?? dmThreadId;
|
||||
const effectiveDmPolicy = resolveTelegramEffectiveDmPolicy({
|
||||
isGroup,
|
||||
groupConfig,
|
||||
@@ -591,9 +639,23 @@ async function resolveTelegramCommandAuth(params: {
|
||||
});
|
||||
|
||||
const sendAuthMessage = async (text: string) => {
|
||||
if (shouldSuppressRejection?.()) {
|
||||
return null;
|
||||
}
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "sendMessage",
|
||||
fn: () => bot.api.sendMessage(chatId, text, threadParams ?? {}),
|
||||
fn: () =>
|
||||
bot.api.sendMessage(chatId, text, {
|
||||
...(isTelegramPeerBotMessage(msg) && replyToMode !== "off"
|
||||
? {
|
||||
reply_parameters: {
|
||||
message_id: msg.message_id,
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...threadParams,
|
||||
}),
|
||||
});
|
||||
return null;
|
||||
};
|
||||
@@ -692,6 +754,7 @@ async function resolveTelegramCommandAuth(params: {
|
||||
isGroup,
|
||||
isForum,
|
||||
resolvedThreadId,
|
||||
...(admissionThreadId != null ? { admissionThreadId } : {}),
|
||||
senderId,
|
||||
senderUsername,
|
||||
groupConfig,
|
||||
@@ -721,7 +784,65 @@ export const registerTelegramNativeCommands = ({
|
||||
shouldSkipUpdate,
|
||||
telegramDeps = defaultTelegramNativeCommandDeps,
|
||||
opts,
|
||||
peerBotAdmission = createTelegramPeerBotAdmissionCoordinator(),
|
||||
}: RegisterTelegramNativeCommandsParams) => {
|
||||
// Peer-bot replies default to explicit threading for Telegram visibility.
|
||||
// Operators can still disable the exception with replyToMode: "off".
|
||||
const peerBotReplyToMode = opts.replyToMode ?? telegramCfg.replyToMode ?? "all";
|
||||
const shouldSuppressPeerBotCommandLoop = (params: {
|
||||
msg: NonNullable<TelegramNativeCommandContext["message"]>;
|
||||
botId?: number;
|
||||
runtimeCfg: OpenClawConfig;
|
||||
}): boolean =>
|
||||
shouldSuppressTelegramBotCommandLoop({
|
||||
msg: params.msg,
|
||||
botId: params.botId,
|
||||
accountId,
|
||||
cfg: params.runtimeCfg,
|
||||
});
|
||||
const admitAuthorizedPeerBotCommand = async (params: {
|
||||
msg: NonNullable<TelegramNativeCommandContext["message"]>;
|
||||
botId?: number;
|
||||
isAbortControl: boolean;
|
||||
threadId?: number;
|
||||
runtimeCfg: OpenClawConfig;
|
||||
}): Promise<boolean> => {
|
||||
if (!isTelegramPeerBotMessage(params.msg) || params.botId == null || !params.msg.from) {
|
||||
return false;
|
||||
}
|
||||
const admissionKey = buildTelegramPeerBotAdmissionKey({
|
||||
accountId,
|
||||
chatId: params.msg.chat.id,
|
||||
threadId: params.threadId,
|
||||
senderId: String(params.msg.from.id),
|
||||
receiverId: params.botId,
|
||||
});
|
||||
if (params.isAbortControl) {
|
||||
// Authorized stop always cancels buffered peer work, even when loop
|
||||
// protection suppresses its command response.
|
||||
await peerBotAdmission.cancel(admissionKey);
|
||||
if (
|
||||
shouldSuppressPeerBotCommandLoop({
|
||||
msg: params.msg,
|
||||
botId: params.botId,
|
||||
runtimeCfg: params.runtimeCfg,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return await peerBotAdmission.reserve(
|
||||
admissionKey,
|
||||
(admitted) =>
|
||||
admitted &&
|
||||
shouldSuppressPeerBotCommandLoop({
|
||||
msg: params.msg,
|
||||
botId: params.botId,
|
||||
runtimeCfg: params.runtimeCfg,
|
||||
}),
|
||||
)(true);
|
||||
};
|
||||
const boundRoute =
|
||||
nativeEnabled && nativeSkillsEnabled
|
||||
? resolveAgentRoute({ cfg, channel: "telegram", accountId })
|
||||
@@ -937,7 +1058,17 @@ export const registerTelegramNativeCommands = ({
|
||||
bot.api.sendMessage(
|
||||
chatId,
|
||||
"Configured ACP binding is unavailable right now. Please try again.",
|
||||
buildTelegramThreadParams(threadSpec) ?? {},
|
||||
{
|
||||
...buildTelegramThreadParams(threadSpec),
|
||||
...(isTelegramPeerBotMessage(msg) && peerBotReplyToMode !== "off"
|
||||
? {
|
||||
reply_parameters: {
|
||||
message_id: msg.message_id,
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
),
|
||||
});
|
||||
return null;
|
||||
@@ -972,6 +1103,8 @@ export const registerTelegramNativeCommands = ({
|
||||
tableMode: ReturnType<typeof resolveMarkdownTableMode>;
|
||||
chunkMode: TelegramChunkMode;
|
||||
linkPreview?: boolean;
|
||||
standardMessages?: boolean;
|
||||
defaultReplyToId?: string;
|
||||
}) => ({
|
||||
cfg: params.cfg,
|
||||
chatId: String(params.chatId),
|
||||
@@ -985,12 +1118,14 @@ export const registerTelegramNativeCommands = ({
|
||||
bot,
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
mediaMaxBytes,
|
||||
replyToMode,
|
||||
replyToMode: params.standardMessages ? peerBotReplyToMode : replyToMode,
|
||||
textLimit,
|
||||
thread: params.threadSpec,
|
||||
tableMode: params.tableMode,
|
||||
chunkMode: params.chunkMode,
|
||||
linkPreview: params.linkPreview,
|
||||
standardMessages: params.standardMessages,
|
||||
defaultReplyToId: params.defaultReplyToId,
|
||||
});
|
||||
const resolveCommandTargetSessionKey = (params: {
|
||||
runtimeCfg: OpenClawConfig;
|
||||
@@ -1031,17 +1166,22 @@ export const registerTelegramNativeCommands = ({
|
||||
if (!msg) {
|
||||
return;
|
||||
}
|
||||
if (msg.from?.id != null && msg.from.id === ctx.me?.id) {
|
||||
return;
|
||||
}
|
||||
if (shouldSkipUpdate(ctx)) {
|
||||
return;
|
||||
}
|
||||
const runtimeCfg = loadFreshRuntimeConfig();
|
||||
const runtimeTelegramCfg = resolveFreshTelegramConfig(runtimeCfg);
|
||||
const botId = ctx.me?.id ?? bot.botInfo?.id;
|
||||
const auth = await resolveTelegramCommandAuth({
|
||||
msg,
|
||||
bot,
|
||||
cfg: runtimeCfg,
|
||||
accountId,
|
||||
telegramCfg: runtimeTelegramCfg,
|
||||
replyToMode: peerBotReplyToMode,
|
||||
readChannelAllowFromStore: telegramDeps.readChannelAllowFromStore,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
@@ -1049,10 +1189,23 @@ export const registerTelegramNativeCommands = ({
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
requireAuth: true,
|
||||
shouldSuppressRejection: () =>
|
||||
shouldSuppressPeerBotCommandLoop({ msg, botId, runtimeCfg }),
|
||||
});
|
||||
if (!auth) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
await admitAuthorizedPeerBotCommand({
|
||||
msg,
|
||||
botId,
|
||||
isAbortControl: normalizedCommandName === "stop",
|
||||
threadId: auth.admissionThreadId,
|
||||
runtimeCfg,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
chatId,
|
||||
isGroup,
|
||||
@@ -1177,6 +1330,14 @@ export const registerTelegramNativeCommands = ({
|
||||
fn: () =>
|
||||
bot.api.sendMessage(chatId, title, {
|
||||
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
...(isTelegramPeerBotMessage(msg) && peerBotReplyToMode !== "off"
|
||||
? {
|
||||
reply_parameters: {
|
||||
message_id: msg.message_id,
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...threadParams,
|
||||
}),
|
||||
});
|
||||
@@ -1195,6 +1356,7 @@ export const registerTelegramNativeCommands = ({
|
||||
userId: String(senderId || chatId),
|
||||
targetSessionKey: sessionKey,
|
||||
});
|
||||
const peerBotCommand = isTelegramPeerBotMessage(msg);
|
||||
const deliveryBaseOptions = buildCommandDeliveryBaseOptions({
|
||||
cfg: executionCfg,
|
||||
chatId,
|
||||
@@ -1208,6 +1370,8 @@ export const registerTelegramNativeCommands = ({
|
||||
tableMode,
|
||||
chunkMode,
|
||||
linkPreview: runtimeTelegramCfg.linkPreview,
|
||||
standardMessages: peerBotCommand,
|
||||
defaultReplyToId: undefined,
|
||||
});
|
||||
let topicName: string | undefined;
|
||||
if (isForum && resolvedThreadId != null) {
|
||||
@@ -1273,10 +1437,12 @@ export const registerTelegramNativeCommands = ({
|
||||
runtime.error?.(danger(`telegram slash: failed updating session meta: ${String(err)}`)),
|
||||
});
|
||||
|
||||
const disableBlockStreaming =
|
||||
resolveTelegramNativeCommandDisableBlockStreaming(runtimeTelegramCfg);
|
||||
const disableBlockStreaming = isTelegramPeerBotMessage(msg)
|
||||
? true
|
||||
: resolveTelegramNativeCommandDisableBlockStreaming(runtimeTelegramCfg);
|
||||
const deliveryState = {
|
||||
delivered: false,
|
||||
failedNonSilent: 0,
|
||||
skippedNonSilent: 0,
|
||||
};
|
||||
|
||||
@@ -1288,60 +1454,142 @@ export const registerTelegramNativeCommands = ({
|
||||
channel: "telegram",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
const peerBotTurn =
|
||||
peerBotCommand && msg.from?.id != null
|
||||
? {
|
||||
accountId: route.accountId,
|
||||
chatAliases: [msg.chat.username]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => `@${value}`),
|
||||
chatId: String(chatId),
|
||||
messageId: msg.message_id,
|
||||
senderAliases: [msg.from?.username]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => `@${value}`),
|
||||
senderId: String(msg.from.id),
|
||||
...(threadSpec.id != null ? { threadId: threadSpec.id } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const effectiveNativeReplyToMode = peerBotCommand ? peerBotReplyToMode : replyToMode;
|
||||
let peerImplicitReplyAvailable = true;
|
||||
const applyPeerImplicitReply = (payload: TelegramNativeReplyPayload) => {
|
||||
if (
|
||||
effectiveNativeReplyToMode === "off" ||
|
||||
payload.replyToId != null ||
|
||||
(isSingleUseReplyToMode(effectiveNativeReplyToMode) && !peerImplicitReplyAvailable)
|
||||
) {
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
replyToId: String(msg.message_id),
|
||||
replyToIdSource: "implicit" as const,
|
||||
};
|
||||
};
|
||||
const commitPeerImplicitReply = (payload: TelegramNativeReplyPayload) => {
|
||||
if (
|
||||
payload.replyToIdSource === "implicit" &&
|
||||
isSingleUseReplyToMode(effectiveNativeReplyToMode)
|
||||
) {
|
||||
peerImplicitReplyAvailable = false;
|
||||
}
|
||||
};
|
||||
const transformQueuedPeerBotPayload = (payload: TelegramNativeReplyPayload) => {
|
||||
const addressedPayload = applyPeerImplicitReply(payload);
|
||||
return {
|
||||
...addressedPayload,
|
||||
channelData: {
|
||||
...addressedPayload.channelData,
|
||||
telegram: {
|
||||
...(addressedPayload.channelData?.telegram as Record<string, unknown> | 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<ReturnType<typeof deliverReplies>>;
|
||||
try {
|
||||
result = await deliverReplies({
|
||||
replies: [addressedPayload],
|
||||
...deliveryBaseOptions,
|
||||
silent:
|
||||
runtimeTelegramCfg.silentErrorReplies === true && payload.isError === true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isTelegramDeliveryErrorVisible(error)) {
|
||||
commitPeerImplicitReply(addressedPayload);
|
||||
deliveryState.delivered = true;
|
||||
}
|
||||
const silentFailure =
|
||||
runtimeTelegramCfg.silentErrorReplies === true && payload.isError === true;
|
||||
if (!silentFailure) {
|
||||
deliveryState.failedNonSilent += 1;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (result.delivered) {
|
||||
commitPeerImplicitReply(addressedPayload);
|
||||
deliveryState.delivered = true;
|
||||
}
|
||||
},
|
||||
onSkip: (_payload, info) => {
|
||||
if (info.reason !== "silent") {
|
||||
deliveryState.skippedNonSilent += 1;
|
||||
}
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`));
|
||||
},
|
||||
},
|
||||
onSkip: (_payload, info) => {
|
||||
if (info.reason !== "silent") {
|
||||
deliveryState.skippedNonSilent += 1;
|
||||
}
|
||||
replyOptions: {
|
||||
skillFilter,
|
||||
disableBlockStreaming,
|
||||
queuedDeliveryPayloadTransform: peerBotCommand
|
||||
? transformQueuedPeerBotPayload
|
||||
: undefined,
|
||||
queuedDeliveryReplyToMode: peerBotCommand ? effectiveNativeReplyToMode : undefined,
|
||||
queuedDeliveryPayloadDidDeliver: peerBotCommand ? commitPeerImplicitReply : undefined,
|
||||
queuedExecutionContext: peerBotTurn
|
||||
? (run) => runWithTelegramPeerBotTurn(peerBotTurn, run)
|
||||
: undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`));
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
skillFilter,
|
||||
disableBlockStreaming,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) {
|
||||
await deliverReplies({
|
||||
replies: [{ text: EMPTY_RESPONSE_FALLBACK }],
|
||||
});
|
||||
await (peerBotTurn
|
||||
? runWithTelegramPeerBotTurn(peerBotTurn, dispatchNativeCommand)
|
||||
: dispatchNativeCommand());
|
||||
if (
|
||||
!deliveryState.delivered &&
|
||||
deliveryState.skippedNonSilent + deliveryState.failedNonSilent > 0
|
||||
) {
|
||||
const fallbackPayload = applyPeerImplicitReply({ text: EMPTY_RESPONSE_FALLBACK });
|
||||
const fallbackResult = await deliverReplies({
|
||||
replies: [fallbackPayload],
|
||||
...deliveryBaseOptions,
|
||||
});
|
||||
if (fallbackResult.delivered) {
|
||||
commitPeerImplicitReply(fallbackPayload);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1352,22 +1600,40 @@ export const registerTelegramNativeCommands = ({
|
||||
if (!msg) {
|
||||
return;
|
||||
}
|
||||
if (msg.from?.id != null && msg.from.id === ctx.me?.id) {
|
||||
return;
|
||||
}
|
||||
if (shouldSkipUpdate(ctx)) {
|
||||
return;
|
||||
}
|
||||
const chatId = msg.chat.id;
|
||||
const runtimeCfg = loadFreshRuntimeConfig();
|
||||
const runtimeTelegramCfg = resolveFreshTelegramConfig(runtimeCfg);
|
||||
const botId = ctx.me?.id ?? bot.botInfo?.id;
|
||||
const { threadParams } = await resolveTelegramNativeCommandThreadContext({ msg, bot });
|
||||
const rawText = ctx.match?.trim() ?? "";
|
||||
const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`;
|
||||
const nativeCommandRuntime = await loadTelegramNativeCommandRuntime();
|
||||
const match = nativeCommandRuntime.matchPluginCommand(commandBody);
|
||||
if (!match) {
|
||||
if (shouldSuppressPeerBotCommandLoop({ msg, botId, runtimeCfg })) {
|
||||
return;
|
||||
}
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "sendMessage",
|
||||
runtime,
|
||||
fn: () => bot.api.sendMessage(chatId, "Command not found.", threadParams ?? {}),
|
||||
fn: () =>
|
||||
bot.api.sendMessage(chatId, "Command not found.", {
|
||||
...(isTelegramPeerBotMessage(msg) && peerBotReplyToMode !== "off"
|
||||
? {
|
||||
reply_parameters: {
|
||||
message_id: msg.message_id,
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...threadParams,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1377,6 +1643,7 @@ export const registerTelegramNativeCommands = ({
|
||||
cfg: runtimeCfg,
|
||||
accountId,
|
||||
telegramCfg: runtimeTelegramCfg,
|
||||
replyToMode: peerBotReplyToMode,
|
||||
readChannelAllowFromStore: telegramDeps.readChannelAllowFromStore,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
@@ -1384,10 +1651,23 @@ export const registerTelegramNativeCommands = ({
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
requireAuth: match.command.requireAuth !== false,
|
||||
shouldSuppressRejection: () =>
|
||||
shouldSuppressPeerBotCommandLoop({ msg, botId, runtimeCfg }),
|
||||
});
|
||||
if (!auth) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
await admitAuthorizedPeerBotCommand({
|
||||
msg,
|
||||
botId,
|
||||
isAbortControl: false,
|
||||
threadId: auth.admissionThreadId,
|
||||
runtimeCfg,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { senderId, commandAuthorized, senderIsOwner, isGroup, isForum, resolvedThreadId } =
|
||||
auth;
|
||||
const runtimeContext = await resolveCommandRuntimeContext({
|
||||
@@ -1430,6 +1710,11 @@ export const registerTelegramNativeCommands = ({
|
||||
tableMode,
|
||||
chunkMode,
|
||||
linkPreview: runtimeTelegramCfg.linkPreview,
|
||||
standardMessages: isTelegramPeerBotMessage(msg),
|
||||
defaultReplyToId:
|
||||
isTelegramPeerBotMessage(msg) && peerBotReplyToMode !== "off"
|
||||
? String(msg.message_id)
|
||||
: undefined,
|
||||
});
|
||||
const from = isGroup ? buildTelegramGroupFrom(chatId, threadSpec.id) : `telegram:${chatId}`;
|
||||
const to = `telegram:${chatId}`;
|
||||
@@ -1438,7 +1723,9 @@ export const registerTelegramNativeCommands = ({
|
||||
let progressMessageId: number | undefined;
|
||||
const progressPlaceholder = resolveTelegramProgressPlaceholder(match.command);
|
||||
|
||||
if (progressPlaceholder) {
|
||||
// Peer bots do not receive rich edits, so bot-originated commands must
|
||||
// wait for the observable standard final instead of a progress placeholder.
|
||||
if (progressPlaceholder && deliveryBaseOptions.standardMessages !== true) {
|
||||
try {
|
||||
const sent = await withTelegramApiErrorLogging({
|
||||
operation: "sendMessage",
|
||||
@@ -1505,9 +1792,19 @@ export const registerTelegramNativeCommands = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const deliverableResult = hasRenderableTelegramNativeReplyPayload(result)
|
||||
const baseDeliverableResult = hasRenderableTelegramNativeReplyPayload(result)
|
||||
? result
|
||||
: { text: EMPTY_RESPONSE_FALLBACK };
|
||||
const deliverableResult =
|
||||
isTelegramPeerBotMessage(msg) &&
|
||||
peerBotReplyToMode !== "off" &&
|
||||
baseDeliverableResult.replyToId == null
|
||||
? {
|
||||
...baseDeliverableResult,
|
||||
replyToId: String(msg.message_id),
|
||||
replyToIdSource: "implicit" as const,
|
||||
}
|
||||
: baseDeliverableResult;
|
||||
const progressResultText =
|
||||
typeof deliverableResult.text === "string" && deliverableResult.text.trim().length > 0
|
||||
? deliverableResult.text
|
||||
|
||||
@@ -33,6 +33,7 @@ type ReplyPayloadLike = {
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
replyToId?: string;
|
||||
replyToIdSource?: "explicit" | "implicit";
|
||||
};
|
||||
|
||||
const { sessionStorePath } = vi.hoisted(() => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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. */
|
||||
|
||||
@@ -31,10 +31,15 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
|
||||
import { resolveTelegramInlineButtons, type TelegramInlineButtons } from "../button-types.js";
|
||||
import { splitTelegramCaption } from "../caption.js";
|
||||
import { markTelegramDeliveryErrorVisible } from "../delivery-error.js";
|
||||
import { renderTelegramHtmlText } from "../format.js";
|
||||
import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js";
|
||||
import { splitTelegramRichMarkdownChunks, TELEGRAM_RICH_TEXT_LIMIT } from "../rich-message.js";
|
||||
import { buildInlineKeyboard } from "../send.js";
|
||||
import {
|
||||
buildTelegramStandardFragmentAbort,
|
||||
buildTelegramStandardTextChunks,
|
||||
} from "../standard-text.js";
|
||||
import { resolveTelegramVoiceSend } from "../voice.js";
|
||||
import {
|
||||
buildTelegramSendParams,
|
||||
@@ -73,6 +78,8 @@ type TelegramReplyQuoteForSend = {
|
||||
|
||||
type TelegramTextChunk = {
|
||||
text: string;
|
||||
plainText?: string;
|
||||
textMode?: "html";
|
||||
};
|
||||
|
||||
type ChunkTextFn = (markdown: string) => TelegramTextChunk[];
|
||||
@@ -81,7 +88,20 @@ function buildChunkTextResolver(params: {
|
||||
textLimit: number;
|
||||
chunkMode: ChunkMode;
|
||||
tableMode?: MarkdownTableMode;
|
||||
standardMessages?: boolean;
|
||||
}): ChunkTextFn {
|
||||
if (params.standardMessages) {
|
||||
return (markdown: string) =>
|
||||
buildTelegramStandardTextChunks(markdown, { tableMode: params.tableMode }).map((chunk) =>
|
||||
Object.assign(
|
||||
{
|
||||
text: chunk.htmlText ?? chunk.plainText,
|
||||
plainText: chunk.plainText,
|
||||
},
|
||||
chunk.htmlText ? { textMode: "html" as const } : {},
|
||||
),
|
||||
);
|
||||
}
|
||||
return (markdown: string) => {
|
||||
return splitTelegramRichMarkdownChunks(markdown, params.textLimit, params.chunkMode).map(
|
||||
(text) => ({
|
||||
@@ -102,6 +122,59 @@ function filterEmptyTelegramTextChunks<T extends { text: string }>(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<void> {
|
||||
const firstChunk = params.chunks[0];
|
||||
const abortText = buildTelegramStandardFragmentAbort(
|
||||
firstChunk?.plainText ?? firstChunk?.text ?? "",
|
||||
);
|
||||
if (!abortText) {
|
||||
return;
|
||||
}
|
||||
await sendTelegramText(params.bot, params.chatId, abortText, params.runtime, {
|
||||
thread: params.thread,
|
||||
replyToMessageId: params.replyToMessageId,
|
||||
standardMessage: { plainText: abortText },
|
||||
silent: params.silent,
|
||||
}).catch((error: unknown) => {
|
||||
logVerbose(`telegram framed batch abort failed: ${String(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveReplyQuoteForSend(params: {
|
||||
replyToId?: number;
|
||||
replyQuoteByMessageId?: TelegramNativeQuoteCandidateByMessageId;
|
||||
@@ -162,38 +235,65 @@ async function deliverTextReply(params: {
|
||||
}): Promise<number | undefined> {
|
||||
let firstDeliveredMessageId: number | undefined;
|
||||
const chunks = filterEmptyTelegramTextChunks(params.chunkText(params.replyText));
|
||||
await sendChunkedTelegramReplyText({
|
||||
chunks,
|
||||
progress: params.progress,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
replyMarkup: params.replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
markDelivered,
|
||||
sendChunk: async ({ chunk, replyToMessageId, replyMarkup, replyQuoteText }) => {
|
||||
const messageId = await sendTelegramText(
|
||||
params.bot,
|
||||
params.chatId,
|
||||
chunk.text,
|
||||
params.runtime,
|
||||
{
|
||||
replyToMessageId,
|
||||
replyQuoteMessageId: params.replyQuoteMessageId,
|
||||
replyQuoteText,
|
||||
replyQuotePosition: params.replyQuotePosition,
|
||||
replyQuoteEntities: params.replyQuoteEntities,
|
||||
const progressBeforeBatch = { ...params.progress };
|
||||
try {
|
||||
await sendChunkedTelegramReplyText({
|
||||
chunks,
|
||||
progress: params.progress,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: resolveTelegramTextChunkReplyToMode(chunks, params.replyToMode),
|
||||
replyMarkup: params.replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
markDelivered,
|
||||
sendChunk: async ({ chunk, replyToMessageId, replyMarkup, replyQuoteText }) => {
|
||||
const messageId = await sendTelegramText(
|
||||
params.bot,
|
||||
params.chatId,
|
||||
chunk.text,
|
||||
params.runtime,
|
||||
{
|
||||
replyToMessageId,
|
||||
replyQuoteMessageId: params.replyQuoteMessageId,
|
||||
replyQuoteText,
|
||||
replyQuotePosition: params.replyQuotePosition,
|
||||
replyQuoteEntities: params.replyQuoteEntities,
|
||||
thread: params.thread,
|
||||
textMode: chunk.textMode ?? "markdown",
|
||||
...(chunk.plainText !== undefined
|
||||
? { standardMessage: { plainText: chunk.plainText } }
|
||||
: {}),
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup,
|
||||
},
|
||||
);
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = messageId;
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isFramedStandardTextBatch(chunks)) {
|
||||
if (madeFramedBatchProgress(params.progress, progressBeforeBatch)) {
|
||||
await retireIncompleteFramedBatch({
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
textMode: "markdown",
|
||||
linkPreview: params.linkPreview,
|
||||
replyToMessageId: params.replyToId,
|
||||
chunks,
|
||||
silent: params.silent,
|
||||
replyMarkup,
|
||||
},
|
||||
);
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = messageId;
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
// A peer cannot consume a prefix without the end frame. Keep the logical
|
||||
// turn undelivered so the caller can send a visible terminal fallback.
|
||||
restoreIncompleteFramedBatchProgress(params.progress, progressBeforeBatch);
|
||||
}
|
||||
if (params.progress.hasDelivered) {
|
||||
throw markTelegramDeliveryErrorVisible(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return firstDeliveredMessageId;
|
||||
}
|
||||
|
||||
@@ -212,24 +312,49 @@ async function sendPendingFollowUpText(params: {
|
||||
progress: DeliveryProgress;
|
||||
}): Promise<void> {
|
||||
const chunks = filterEmptyTelegramTextChunks(params.chunkText(params.text));
|
||||
await sendChunkedTelegramReplyText({
|
||||
chunks,
|
||||
progress: params.progress,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
replyMarkup: params.replyMarkup,
|
||||
markDelivered,
|
||||
sendChunk: async ({ chunk, replyToMessageId, replyMarkup }) => {
|
||||
await sendTelegramText(params.bot, params.chatId, chunk.text, params.runtime, {
|
||||
replyToMessageId,
|
||||
thread: params.thread,
|
||||
textMode: "markdown",
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup,
|
||||
});
|
||||
},
|
||||
});
|
||||
const progressBeforeBatch = { ...params.progress };
|
||||
try {
|
||||
await sendChunkedTelegramReplyText({
|
||||
chunks,
|
||||
progress: params.progress,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: resolveTelegramTextChunkReplyToMode(chunks, params.replyToMode),
|
||||
replyMarkup: params.replyMarkup,
|
||||
markDelivered,
|
||||
sendChunk: async ({ chunk, replyToMessageId, replyMarkup }) => {
|
||||
await sendTelegramText(params.bot, params.chatId, chunk.text, params.runtime, {
|
||||
replyToMessageId,
|
||||
thread: params.thread,
|
||||
textMode: chunk.textMode ?? "markdown",
|
||||
...(chunk.plainText !== undefined
|
||||
? { standardMessage: { plainText: chunk.plainText } }
|
||||
: {}),
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup,
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isFramedStandardTextBatch(chunks)) {
|
||||
if (madeFramedBatchProgress(params.progress, progressBeforeBatch)) {
|
||||
await retireIncompleteFramedBatch({
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
replyToMessageId: params.replyToId,
|
||||
chunks,
|
||||
silent: params.silent,
|
||||
});
|
||||
}
|
||||
restoreIncompleteFramedBatchProgress(params.progress, progressBeforeBatch);
|
||||
}
|
||||
if (params.progress.hasDelivered) {
|
||||
throw markTelegramDeliveryErrorVisible(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isVoiceMessagesForbidden(err: unknown): boolean {
|
||||
@@ -263,6 +388,8 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
text: string;
|
||||
chunkText: (markdown: string) => TelegramTextChunk[];
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
replyQuoteMessageId?: number;
|
||||
replyQuotePosition?: number;
|
||||
replyQuoteEntities?: unknown[];
|
||||
@@ -274,29 +401,59 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
}): Promise<number | undefined> {
|
||||
let firstDeliveredMessageId: number | undefined;
|
||||
const chunks = filterEmptyTelegramTextChunks(opts.chunkText(opts.text));
|
||||
let appliedReplyTo = false;
|
||||
for (const chunk of chunks) {
|
||||
// Only apply reply reference, quote text, and buttons to the first chunk.
|
||||
const replyToForChunk = !appliedReplyTo ? opts.replyToId : undefined;
|
||||
const applyQuoteForChunk = !appliedReplyTo;
|
||||
const messageId = await sendTelegramText(opts.bot, opts.chatId, chunk.text, opts.runtime, {
|
||||
replyToMessageId: replyToForChunk,
|
||||
replyQuoteMessageId: applyQuoteForChunk ? opts.replyQuoteMessageId : undefined,
|
||||
replyQuoteText: applyQuoteForChunk ? opts.replyQuoteText : undefined,
|
||||
replyQuotePosition: applyQuoteForChunk ? opts.replyQuotePosition : undefined,
|
||||
replyQuoteEntities: applyQuoteForChunk ? opts.replyQuoteEntities : undefined,
|
||||
thread: opts.thread,
|
||||
textMode: "markdown",
|
||||
linkPreview: opts.linkPreview,
|
||||
silent: opts.silent,
|
||||
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
|
||||
const progressBeforeBatch = { ...opts.progress };
|
||||
try {
|
||||
await sendChunkedTelegramReplyText({
|
||||
chunks,
|
||||
progress: opts.progress,
|
||||
replyToId: opts.replyToId,
|
||||
replyToMode: resolveTelegramTextChunkReplyToMode(chunks, opts.replyToMode),
|
||||
replyMarkup: opts.replyMarkup,
|
||||
replyQuoteText: opts.replyQuoteText,
|
||||
quoteOnlyOnFirstChunk: true,
|
||||
// Track visible chunks immediately; the caller increments the logical reply
|
||||
// count once after the complete fallback succeeds.
|
||||
markDelivered: (progress) => {
|
||||
progress.hasDelivered = true;
|
||||
},
|
||||
sendChunk: async ({ chunk, isFirstChunk, replyToMessageId, replyMarkup, replyQuoteText }) => {
|
||||
const messageId = await sendTelegramText(opts.bot, opts.chatId, chunk.text, opts.runtime, {
|
||||
replyToMessageId,
|
||||
replyQuoteMessageId: isFirstChunk ? opts.replyQuoteMessageId : undefined,
|
||||
replyQuoteText,
|
||||
replyQuotePosition: isFirstChunk ? opts.replyQuotePosition : undefined,
|
||||
replyQuoteEntities: isFirstChunk ? opts.replyQuoteEntities : undefined,
|
||||
thread: opts.thread,
|
||||
textMode: chunk.textMode ?? "markdown",
|
||||
...(chunk.plainText !== undefined
|
||||
? { standardMessage: { plainText: chunk.plainText } }
|
||||
: {}),
|
||||
linkPreview: opts.linkPreview,
|
||||
silent: opts.silent,
|
||||
replyMarkup,
|
||||
});
|
||||
firstDeliveredMessageId ??= messageId;
|
||||
},
|
||||
});
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = messageId;
|
||||
} catch (error) {
|
||||
if (isFramedStandardTextBatch(chunks)) {
|
||||
if (madeFramedBatchProgress(opts.progress, progressBeforeBatch)) {
|
||||
await retireIncompleteFramedBatch({
|
||||
bot: opts.bot,
|
||||
chatId: opts.chatId,
|
||||
runtime: opts.runtime,
|
||||
thread: opts.thread,
|
||||
replyToMessageId: opts.replyToId,
|
||||
chunks,
|
||||
silent: opts.silent,
|
||||
});
|
||||
}
|
||||
restoreIncompleteFramedBatchProgress(opts.progress, progressBeforeBatch);
|
||||
}
|
||||
if (replyToForChunk) {
|
||||
appliedReplyTo = true;
|
||||
if (opts.progress.hasDelivered) {
|
||||
throw markTelegramDeliveryErrorVisible(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return firstDeliveredMessageId;
|
||||
}
|
||||
@@ -466,6 +623,8 @@ async function deliverMediaReply(params: {
|
||||
text: fallbackText,
|
||||
chunkText: params.chunkText,
|
||||
replyToId: voiceFallbackReplyTo,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
replyQuoteMessageId: params.replyQuoteMessageId,
|
||||
replyQuotePosition: params.replyQuotePosition,
|
||||
replyQuoteEntities: params.replyQuoteEntities,
|
||||
@@ -491,6 +650,7 @@ async function deliverMediaReply(params: {
|
||||
delete noCaptionParams.caption;
|
||||
delete noCaptionParams.parse_mode;
|
||||
await sendVoiceMedia(noCaptionParams);
|
||||
markReplyApplied(params.progress, replyToMessageId);
|
||||
const fallbackText = resolveVoiceFallbackText(params.reply);
|
||||
if (fallbackText?.trim()) {
|
||||
await sendTelegramVoiceFallbackText({
|
||||
@@ -499,7 +659,9 @@ async function deliverMediaReply(params: {
|
||||
runtime: params.runtime,
|
||||
text: fallbackText,
|
||||
chunkText: params.chunkText,
|
||||
replyToId: undefined,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
thread: params.thread,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
@@ -682,6 +844,10 @@ export async function deliverReplies(params: {
|
||||
thread?: TelegramThreadSpec | null;
|
||||
tableMode?: MarkdownTableMode;
|
||||
chunkMode?: ChunkMode;
|
||||
/** Standard Bot API messages remain visible to bot-originated QA/automation turns. */
|
||||
standardMessages?: boolean;
|
||||
/** Reply target synthesized for bot-originated terminal/native responses. */
|
||||
defaultReplyToId?: string;
|
||||
/** Callback invoked before sending a voice message to switch typing indicator. */
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
/** Controls whether link previews are shown. Default: true (previews enabled). */
|
||||
@@ -717,6 +883,7 @@ export async function deliverReplies(params: {
|
||||
textLimit: Math.min(params.textLimit, TELEGRAM_RICH_TEXT_LIMIT),
|
||||
chunkMode: params.chunkMode ?? "length",
|
||||
tableMode: params.tableMode,
|
||||
standardMessages: params.standardMessages,
|
||||
});
|
||||
const candidateReplies: ReplyPayload[] = [];
|
||||
for (const reply of params.replies) {
|
||||
@@ -769,8 +936,18 @@ export async function deliverReplies(params: {
|
||||
? reply.spokenText
|
||||
: undefined;
|
||||
const hookContent = spokenHookContent ?? rawContent;
|
||||
// Parsed reply directives predate provenance stamps; their explicit tag
|
||||
// fields remain authoritative for direct native-command delivery.
|
||||
const hasExplicitReplyTarget =
|
||||
reply.replyToId != null &&
|
||||
(reply.replyToIdSource !== "implicit" ||
|
||||
reply.replyToTag === true ||
|
||||
reply.replyToCurrent === true);
|
||||
const replyToId =
|
||||
params.replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId);
|
||||
hasExplicitReplyTarget || params.replyToMode !== "off"
|
||||
? resolveTelegramReplyId(reply.replyToId ?? params.defaultReplyToId)
|
||||
: undefined;
|
||||
const effectiveReplyToMode: ReplyToMode = hasExplicitReplyTarget ? "all" : params.replyToMode;
|
||||
const replyQuote = resolveReplyQuoteForSend({
|
||||
replyToId,
|
||||
replyQuoteByMessageId: params.replyQuoteByMessageId,
|
||||
@@ -838,7 +1015,7 @@ export async function deliverReplies(params: {
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
replyToMode: effectiveReplyToMode,
|
||||
progress,
|
||||
});
|
||||
} else {
|
||||
@@ -863,7 +1040,7 @@ export async function deliverReplies(params: {
|
||||
replyQuoteEntities: replyQuote.entities,
|
||||
replyMarkup,
|
||||
replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
replyToMode: effectiveReplyToMode,
|
||||
progress,
|
||||
});
|
||||
firstDeliveredMessageId = mediaDelivery.firstDeliveredMessageId;
|
||||
@@ -908,7 +1085,7 @@ export async function deliverReplies(params: {
|
||||
isGroup: params.mirrorIsGroup,
|
||||
groupId: params.mirrorGroupId,
|
||||
});
|
||||
throw error;
|
||||
throw progress.hasDelivered ? markTelegramDeliveryErrorVisible(error) : error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { TelegramThreadSpec } from "./helpers.js";
|
||||
export { buildTelegramSendParams } from "../reply-parameters.js";
|
||||
|
||||
const QUOTE_PARAM_RE = /\bquote not found\b|\bQUOTE_TEXT_INVALID\b|\bquote text invalid\b/i;
|
||||
const HTML_PARSE_ERROR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
||||
const GrammyErrorCtor: typeof GrammyError | undefined =
|
||||
typeof GrammyError === "function" ? GrammyError : undefined;
|
||||
|
||||
@@ -99,6 +100,7 @@ export async function sendTelegramText(
|
||||
replyQuoteEntities?: unknown[];
|
||||
thread?: TelegramThreadSpec | null;
|
||||
textMode?: "markdown" | "html";
|
||||
standardMessage?: { plainText: string };
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
@@ -115,6 +117,37 @@ export async function sendTelegramText(
|
||||
});
|
||||
const richParams = toTelegramRichMessageContextParams(baseParams);
|
||||
const textMode = opts?.textMode ?? "markdown";
|
||||
if (opts?.standardMessage) {
|
||||
const sendStandard = async (message: string, parseMode?: "HTML") => {
|
||||
const res = await sendTelegramWithThreadFallback({
|
||||
operation: "sendMessage",
|
||||
runtime,
|
||||
thread: opts.thread,
|
||||
requestParams: baseParams,
|
||||
send: (effectiveParams) =>
|
||||
bot.api.sendMessage(chatId, message, {
|
||||
...(parseMode ? { parse_mode: parseMode } : {}),
|
||||
...(opts.linkPreview === false ? { link_preview_options: { is_disabled: true } } : {}),
|
||||
...(opts.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
||||
...effectiveParams,
|
||||
}),
|
||||
});
|
||||
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id}`);
|
||||
return res.message_id;
|
||||
};
|
||||
if (text === opts.standardMessage.plainText) {
|
||||
return await sendStandard(text);
|
||||
}
|
||||
try {
|
||||
return await sendStandard(text, "HTML");
|
||||
} catch (err) {
|
||||
if (!HTML_PARSE_ERROR_RE.test(formatErrorMessage(err))) {
|
||||
throw err;
|
||||
}
|
||||
runtime.log?.("telegram formatted send failed; retrying without formatting");
|
||||
return await sendStandard(opts.standardMessage.plainText);
|
||||
}
|
||||
}
|
||||
const richMessage = buildTelegramRichMessage(text, textMode, {
|
||||
skipEntityDetection: opts?.linkPreview === false,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
import type { Bot } from "grammy";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveTelegramStandardFragmentFrame,
|
||||
stripTelegramStandardFragmentMarker,
|
||||
TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS,
|
||||
} from "../standard-text.js";
|
||||
const { loadWebMedia } = vi.hoisted(() => ({
|
||||
loadWebMedia: vi.fn(),
|
||||
}));
|
||||
@@ -813,6 +818,58 @@ describe("deliverReplies", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("marks later media failures as partial after visible delivery", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendPhoto = vi.fn().mockResolvedValue({ message_id: 13, chat: { id: "123" } });
|
||||
const bot = createBot({ sendPhoto });
|
||||
loadWebMedia
|
||||
.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("first"),
|
||||
contentType: "image/jpeg",
|
||||
fileName: "first.jpg",
|
||||
})
|
||||
.mockRejectedValueOnce(new Error("second media load failed"));
|
||||
|
||||
await expect(
|
||||
deliverWith({
|
||||
replies: [
|
||||
{
|
||||
mediaUrls: ["https://example.com/first.jpg", "https://example.com/second.jpg"],
|
||||
},
|
||||
],
|
||||
runtime,
|
||||
bot,
|
||||
}),
|
||||
).rejects.toMatchObject({ sentBeforeError: true, visibleReplySent: true });
|
||||
|
||||
expect(sendPhoto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps peer reply targeting on standard media follow-up text", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendPhoto = vi.fn().mockResolvedValue({ message_id: 13, chat: { id: "123" } });
|
||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 14, chat: { id: "123" } });
|
||||
const bot = createBot({ sendPhoto, sendMessage });
|
||||
mockMediaLoad("photo.jpg", "image/jpeg", "image");
|
||||
|
||||
await deliverWith({
|
||||
replies: [
|
||||
{
|
||||
mediaUrl: "https://example.com/photo.jpg",
|
||||
text: "A".repeat(1_100),
|
||||
replyToId: "700",
|
||||
},
|
||||
],
|
||||
runtime,
|
||||
bot,
|
||||
standardMessages: true,
|
||||
replyToMode: "first",
|
||||
});
|
||||
|
||||
expectRecordFields(mockCallArg(sendPhoto, 0, 2), { reply_to_message_id: 700 });
|
||||
expectRecordFields(mockCallArg(sendMessage, 0, 2), { reply_to_message_id: 700 });
|
||||
});
|
||||
|
||||
it("skips rich entity detection when link previews are disabled", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
@@ -833,6 +890,230 @@ describe("deliverReplies", () => {
|
||||
expectRecordFields(mockCallArg(sendMessage, 0, 2), { skip_entity_detection: true });
|
||||
});
|
||||
|
||||
it("uses standard messages for bot-originated reply delivery", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 3, chat: { id: "123" } });
|
||||
const sendRichMessage = vi.fn();
|
||||
const bot = { api: { sendMessage, raw: { sendRichMessage } } } as unknown as Bot;
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "**Ready**" }],
|
||||
runtime,
|
||||
bot,
|
||||
standardMessages: true,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"<b>Ready</b>",
|
||||
expect.objectContaining({ parse_mode: "HTML" }),
|
||||
);
|
||||
expect(sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("chunks standard bot-originated replies at the sendMessage limit", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 3, chat: { id: "123" } });
|
||||
const bot = { api: { sendMessage } } as unknown as Bot;
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "A".repeat(40_000), replyToId: "700" }],
|
||||
runtime,
|
||||
bot,
|
||||
standardMessages: true,
|
||||
textLimit: 100_000,
|
||||
replyToMode: "all",
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls).toHaveLength(10);
|
||||
expect(sendMessage.mock.calls.every((call) => String(call[1]).length <= 4096)).toBe(true);
|
||||
expect(sendMessage.mock.calls.every((call) => String(call[1]).startsWith("\u2060"))).toBe(true);
|
||||
expect(
|
||||
sendMessage.mock.calls.slice(0, -1).every((call) => String(call[1]).length >= 4000),
|
||||
).toBe(true);
|
||||
expect(
|
||||
sendMessage.mock.calls.every(
|
||||
(call) => (call[2] as { reply_to_message_id?: number }).reply_to_message_id === 700,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects standard replies that exceed the framed receiver limit before sending", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn();
|
||||
const bot = { api: { sendMessage } } as unknown as Bot;
|
||||
const oversizedText = "A".repeat(4_094 * TELEGRAM_STANDARD_FRAGMENT_MAX_PARTS + 1);
|
||||
|
||||
await expect(
|
||||
deliverWith({
|
||||
replies: [{ text: oversizedText }],
|
||||
runtime,
|
||||
bot,
|
||||
standardMessages: true,
|
||||
textLimit: oversizedText.length,
|
||||
}),
|
||||
).rejects.toThrow(/fragment safety limit/);
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps single-use peer reply targets on every standard transport fragment", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 3, chat: { id: "123" } });
|
||||
const bot = { api: { sendMessage } } as unknown as Bot;
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "A".repeat(5_000), replyToId: "700" }],
|
||||
runtime,
|
||||
bot,
|
||||
standardMessages: true,
|
||||
textLimit: 100_000,
|
||||
replyToMode: "first",
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls).toHaveLength(2);
|
||||
expect(
|
||||
sendMessage.mock.calls.every(
|
||||
(call) => (call[2] as { reply_to_message_id?: number }).reply_to_message_id === 700,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/status continuation",
|
||||
"stop",
|
||||
`${" ".repeat(96)}/reset`,
|
||||
`${" ".repeat(96)}stop`,
|
||||
`/${"B".repeat(4_095)}`,
|
||||
])("keeps %s from starting a standard-message continuation chunk", async (continuation) => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 3, chat: { id: "123" } });
|
||||
const bot = { api: { sendMessage } } as unknown as Bot;
|
||||
const text = `${"A".repeat(4_094)}${continuation}`;
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text }],
|
||||
runtime,
|
||||
bot,
|
||||
standardMessages: true,
|
||||
textLimit: 100_000,
|
||||
});
|
||||
|
||||
const sentChunks = sendMessage.mock.calls.map((call) => String(call[1]));
|
||||
expect(sentChunks.length).toBeGreaterThanOrEqual(2);
|
||||
expect(sentChunks.every((chunk) => chunk.length <= 4_096)).toBe(true);
|
||||
expect(sentChunks.every((chunk) => chunk.startsWith("\u2060"))).toBe(true);
|
||||
expect(sentChunks.map(stripTelegramStandardFragmentMarker).join("")).toBe(text);
|
||||
expect(sentChunks[0]?.length).toBeGreaterThanOrEqual(4_000);
|
||||
expect(sentChunks[1]?.trimStart()).not.toMatch(/^\//);
|
||||
expect(sentChunks[1]?.trimStart().toLowerCase()).not.toBe("stop");
|
||||
});
|
||||
|
||||
it("keeps incomplete standard-message batches eligible for fallback", async () => {
|
||||
const runtime = createRuntime();
|
||||
const failure = new Error("second chunk failed");
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ message_id: 3, chat: { id: "123" } })
|
||||
.mockRejectedValueOnce(failure)
|
||||
.mockResolvedValueOnce({ message_id: 4, chat: { id: "123" } });
|
||||
const bot = { api: { sendMessage } } as unknown as Bot;
|
||||
|
||||
await expect(
|
||||
deliverWith({
|
||||
replies: [{ text: "A".repeat(5_000) }],
|
||||
runtime,
|
||||
bot,
|
||||
standardMessages: true,
|
||||
textLimit: 100_000,
|
||||
}),
|
||||
).rejects.toBe(failure);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(3);
|
||||
const startFrame = resolveTelegramStandardFragmentFrame(String(sendMessage.mock.calls[0]?.[1]));
|
||||
expect(resolveTelegramStandardFragmentFrame(String(sendMessage.mock.calls[2]?.[1]))).toEqual({
|
||||
batchId: startFrame?.batchId,
|
||||
kind: "abort",
|
||||
});
|
||||
expect(failure).not.toHaveProperty("sentBeforeError");
|
||||
expect(failure).not.toHaveProperty("visibleReplySent");
|
||||
});
|
||||
|
||||
it("keeps an incomplete standard follow-up partial after visible media", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendPhoto = vi.fn().mockResolvedValue({ message_id: 13, chat: { id: "123" } });
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ message_id: 14, chat: { id: "123" } })
|
||||
.mockRejectedValueOnce(new Error("second follow-up fragment failed"))
|
||||
.mockResolvedValueOnce({ message_id: 15, chat: { id: "123" } });
|
||||
const bot = createBot({ sendPhoto, sendMessage });
|
||||
mockMediaLoad("photo.jpg", "image/jpeg", "image");
|
||||
|
||||
await expect(
|
||||
deliverWith({
|
||||
replies: [
|
||||
{
|
||||
mediaUrl: "https://example.com/photo.jpg",
|
||||
text: "A".repeat(6_000),
|
||||
},
|
||||
],
|
||||
runtime,
|
||||
bot,
|
||||
standardMessages: true,
|
||||
textLimit: 100_000,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
message: "second follow-up fragment failed",
|
||||
sentBeforeError: true,
|
||||
visibleReplySent: true,
|
||||
});
|
||||
|
||||
expect(sendPhoto).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledTimes(3);
|
||||
expect(resolveTelegramStandardFragmentFrame(String(sendMessage.mock.calls[2]?.[1]))?.kind).toBe(
|
||||
"abort",
|
||||
);
|
||||
});
|
||||
|
||||
it("chunks long parser-empty standard-message fallbacks", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 3, chat: { id: "123" } });
|
||||
const bot = { api: { sendMessage } } as unknown as Bot;
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: Array.from({ length: 5_000 }, () => ">").join("\n") }],
|
||||
runtime,
|
||||
bot,
|
||||
standardMessages: true,
|
||||
textLimit: 100_000,
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(sendMessage.mock.calls.every((call) => String(call[1]).length <= 4096)).toBe(true);
|
||||
});
|
||||
|
||||
it("retries standard bot-originated replies as plain text after HTML parse errors", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities"))
|
||||
.mockResolvedValueOnce({ message_id: 3, chat: { id: "123" } });
|
||||
const bot = { api: { sendMessage } } as unknown as Bot;
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "[docs](https://example.com)" }],
|
||||
runtime,
|
||||
bot,
|
||||
standardMessages: true,
|
||||
});
|
||||
|
||||
expect(firstMockCallArg(sendMessage, 1)).toBe('<a href="https://example.com">docs</a>');
|
||||
expect(mockCallArg(sendMessage, 0, 2)).toHaveProperty("parse_mode", "HTML");
|
||||
expect(mockCallArg(sendMessage, 1, 1)).toBe("docs (https://example.com)");
|
||||
expect(mockCallArg(sendMessage, 1, 2)).not.toHaveProperty("parse_mode");
|
||||
});
|
||||
|
||||
it("includes message_thread_id for DM topics", async () => {
|
||||
const { runtime, sendMessage, bot } = createSendMessageHarness();
|
||||
|
||||
@@ -1132,7 +1413,13 @@ describe("deliverReplies", () => {
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "Hello there", replyToId: "500" }],
|
||||
replies: [
|
||||
{
|
||||
text: "Hello there",
|
||||
replyToId: "500",
|
||||
replyToIdSource: "implicit",
|
||||
},
|
||||
],
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "off",
|
||||
@@ -1332,6 +1619,7 @@ describe("deliverReplies", () => {
|
||||
mediaUrl: "https://example.com/note.ogg",
|
||||
text: "chunk-one\n\nchunk-two",
|
||||
replyToId: "77",
|
||||
replyToIdSource: "implicit",
|
||||
audioAsVoice: true,
|
||||
channelData: {
|
||||
telegram: {
|
||||
@@ -1365,6 +1653,80 @@ describe("deliverReplies", () => {
|
||||
expect(mockCallArg(sendMessage, 1, 2)).not.toHaveProperty("reply_markup");
|
||||
});
|
||||
|
||||
it("keeps peer reply targeting on every standard voice-fallback fragment", async () => {
|
||||
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
|
||||
voiceError: createVoiceMessagesForbiddenError(),
|
||||
sendMessageResult: { message_id: 6, chat: { id: "123" } },
|
||||
});
|
||||
mockMediaLoad("note.ogg", "audio/ogg", "voice");
|
||||
|
||||
await deliverWith({
|
||||
replies: [
|
||||
{
|
||||
mediaUrl: "https://example.com/note.ogg",
|
||||
text: "A".repeat(5_000),
|
||||
replyToId: "77",
|
||||
audioAsVoice: true,
|
||||
},
|
||||
],
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "first",
|
||||
standardMessages: true,
|
||||
textLimit: 100_000,
|
||||
});
|
||||
|
||||
expect(sendVoice).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage.mock.calls).toHaveLength(2);
|
||||
expect(
|
||||
sendMessage.mock.calls.every(
|
||||
(call) => (call[2] as { reply_to_message_id?: number }).reply_to_message_id === 77,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps incomplete standard voice fallbacks eligible for fallback", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendVoice = vi.fn().mockRejectedValue(createVoiceMessagesForbiddenError());
|
||||
const failure = new Error("second fallback chunk failed");
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ message_id: 6, chat: { id: "123" } })
|
||||
.mockRejectedValueOnce(failure)
|
||||
.mockResolvedValueOnce({ message_id: 7, chat: { id: "123" } });
|
||||
const bot = createBot({ sendVoice, sendMessage });
|
||||
mockMediaLoad("note.ogg", "audio/ogg", "voice");
|
||||
|
||||
await expect(
|
||||
deliverWith({
|
||||
replies: [
|
||||
{
|
||||
mediaUrl: "https://example.com/note.ogg",
|
||||
text: "A".repeat(5_000),
|
||||
replyToId: "77",
|
||||
audioAsVoice: true,
|
||||
},
|
||||
],
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "first",
|
||||
standardMessages: true,
|
||||
textLimit: 100_000,
|
||||
}),
|
||||
).rejects.toBe(failure);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(3);
|
||||
expect(resolveTelegramStandardFragmentFrame(String(sendMessage.mock.calls[2]?.[1]))?.kind).toBe(
|
||||
"abort",
|
||||
);
|
||||
expectRecordFields(mockCallArg(sendMessage, 2, 2), {
|
||||
reply_to_message_id: 77,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
expect(failure).not.toHaveProperty("sentBeforeError");
|
||||
expect(failure).not.toHaveProperty("visibleReplySent");
|
||||
});
|
||||
|
||||
it("rethrows non-VOICE_MESSAGES_FORBIDDEN errors from sendVoice", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendVoice = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
@@ -1396,7 +1758,13 @@ describe("deliverReplies", () => {
|
||||
|
||||
// Use a small textLimit to force multiple chunks
|
||||
await deliverReplies({
|
||||
replies: [{ text: "chunk-one\n\nchunk-two", replyToId: "700" }],
|
||||
replies: [
|
||||
{
|
||||
text: "chunk-one\n\nchunk-two",
|
||||
replyToId: "700",
|
||||
replyToIdSource: "implicit",
|
||||
},
|
||||
],
|
||||
chatId: "123",
|
||||
token: "tok",
|
||||
runtime,
|
||||
@@ -1415,6 +1783,63 @@ describe("deliverReplies", () => {
|
||||
expect(mockCallArg(sendMessage, 1, 2)).not.toHaveProperty("reply_to_message_id");
|
||||
});
|
||||
|
||||
it("preserves explicit reply targets across chunks when reply mode is off", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 20,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverReplies({
|
||||
replies: [
|
||||
{
|
||||
text: "chunk-one\n\nchunk-two",
|
||||
replyToId: "701",
|
||||
replyToTag: true,
|
||||
},
|
||||
],
|
||||
chatId: "123",
|
||||
token: "tok",
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "off",
|
||||
textLimit: 12,
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
for (const call of sendMessage.mock.calls) {
|
||||
expectRecordFields(call[2], {
|
||||
reply_to_message_id: 701,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("treats an unstamped payload reply target as explicit when reply mode is off", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 20,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverReplies({
|
||||
replies: [{ text: "plugin reply", replyToId: "702" }],
|
||||
chatId: "123",
|
||||
token: "tok",
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "off",
|
||||
textLimit: 4096,
|
||||
});
|
||||
|
||||
expectRecordFields(mockCallArg(sendMessage, 0, 2), {
|
||||
reply_to_message_id: 702,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps reply chunks to Telegram rich message limit", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
@@ -1477,7 +1902,13 @@ describe("deliverReplies", () => {
|
||||
mockMediaLoad("b.jpg", "image/jpeg", "img2");
|
||||
|
||||
await deliverReplies({
|
||||
replies: [{ mediaUrls: ["https://a.jpg", "https://b.jpg"], replyToId: "900" }],
|
||||
replies: [
|
||||
{
|
||||
mediaUrls: ["https://a.jpg", "https://b.jpg"],
|
||||
replyToId: "900",
|
||||
replyToIdSource: "implicit",
|
||||
},
|
||||
],
|
||||
chatId: "123",
|
||||
token: "tok",
|
||||
runtime,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
export type TelegramDeferredAdmissionCallback = (
|
||||
admitted: boolean,
|
||||
cacheMessage?: boolean,
|
||||
) => Promise<boolean>;
|
||||
|
||||
export async function settleTelegramDeferredAdmissionCallbacks(params: {
|
||||
callbacks: TelegramDeferredAdmissionCallback[];
|
||||
admitted: boolean;
|
||||
cacheMessage: boolean;
|
||||
}): Promise<unknown[]> {
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -135,6 +135,55 @@ describe("telegramOutbound", () => {
|
||||
expect(result).toEqual({ channel: "telegram", messageId: "tg-2", chatId: "12345" });
|
||||
});
|
||||
|
||||
it("marks later payload media failures as partial delivery", async () => {
|
||||
const error = new Error("second media send failed");
|
||||
sendMessageTelegramMock
|
||||
.mockResolvedValueOnce({ messageId: "tg-1", chatId: "12345" })
|
||||
.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
telegramOutbound.sendPayload!({
|
||||
cfg: {} as never,
|
||||
to: "12345",
|
||||
text: "",
|
||||
payload: {
|
||||
text: "Album",
|
||||
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
|
||||
},
|
||||
deps: { sendTelegram: sendMessageTelegramMock },
|
||||
}),
|
||||
).rejects.toMatchObject({ sentBeforeError: true, visibleReplySent: true });
|
||||
|
||||
expect(sendMessageTelegramMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("consumes implicit single-use replies across standard payload media", async () => {
|
||||
sendMessageTelegramMock
|
||||
.mockResolvedValueOnce({ messageId: "tg-1", chatId: "12345" })
|
||||
.mockResolvedValueOnce({ messageId: "tg-2", chatId: "12345" });
|
||||
|
||||
await telegramOutbound.sendPayload!({
|
||||
cfg: {} as never,
|
||||
to: "12345",
|
||||
text: "",
|
||||
payload: {
|
||||
text: "Peer reply",
|
||||
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
|
||||
channelData: { telegram: { standardMessage: true } },
|
||||
},
|
||||
replyToId: "900",
|
||||
replyToIdSource: "implicit",
|
||||
replyToMode: "first",
|
||||
deps: { sendTelegram: sendMessageTelegramMock },
|
||||
});
|
||||
|
||||
expect(callOptionsAt(sendMessageTelegramMock, 0, "12345", "Peer reply")).toMatchObject({
|
||||
replyToMessageId: 900,
|
||||
replyToMode: "first",
|
||||
});
|
||||
expect(callOptionsAt(sendMessageTelegramMock, 1, "12345", "").replyToMessageId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses interactive button labels as fallback text for button-only payloads", async () => {
|
||||
sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-buttons", chatId: "12345" });
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
resolvePayloadMediaUrls,
|
||||
sendPayloadMediaSequenceOrFallback,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
import { resolveTelegramInlineButtons } from "./button-types.js";
|
||||
import { markTelegramDeliveryErrorVisible } from "./delivery-error.js";
|
||||
import { resolveTelegramInteractiveTextFallback } from "./interactive-fallback.js";
|
||||
import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js";
|
||||
import { splitTelegramRichTextChunks, TELEGRAM_RICH_TEXT_LIMIT } from "./rich-message.js";
|
||||
@@ -66,6 +68,8 @@ async function resolveTelegramSendContext(params: {
|
||||
deps?: OutboundSendDeps;
|
||||
accountId?: string | null;
|
||||
replyToId?: string | null;
|
||||
replyToMode?: TelegramSendOpts["replyToMode"];
|
||||
replyToIdSource?: TelegramSendOpts["replyToIdSource"];
|
||||
threadId?: string | number | null;
|
||||
formatting?: OutboundDeliveryFormattingOptions;
|
||||
silent?: boolean;
|
||||
@@ -79,6 +83,8 @@ async function resolveTelegramSendContext(params: {
|
||||
textMode?: "html";
|
||||
messageThreadId?: number;
|
||||
replyToMessageId?: number;
|
||||
replyToMode?: TelegramSendOpts["replyToMode"];
|
||||
replyToIdSource?: TelegramSendOpts["replyToIdSource"];
|
||||
accountId?: string;
|
||||
silent?: boolean;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
@@ -92,6 +98,8 @@ async function resolveTelegramSendContext(params: {
|
||||
cfg: params.cfg,
|
||||
messageThreadId: parseTelegramThreadId(params.threadId),
|
||||
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
|
||||
...(params.replyToMode ? { replyToMode: params.replyToMode } : {}),
|
||||
...(params.replyToIdSource ? { replyToIdSource: params.replyToIdSource } : {}),
|
||||
accountId: params.accountId ?? undefined,
|
||||
silent: params.silent,
|
||||
gatewayClientScopes: params.gatewayClientScopes,
|
||||
@@ -125,7 +133,7 @@ export async function sendTelegramPayloadMessages(params: {
|
||||
baseOpts: Omit<NonNullable<TelegramSendOpts>, "buttons" | "mediaUrl" | "quoteText">;
|
||||
}): Promise<Awaited<ReturnType<TelegramSendFn>>> {
|
||||
const telegramData = params.payload.channelData?.telegram as
|
||||
| { buttons?: TelegramInlineButtons; quoteText?: string }
|
||||
| { buttons?: TelegramInlineButtons; quoteText?: string; standardMessage?: boolean }
|
||||
| undefined;
|
||||
const quoteText =
|
||||
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
|
||||
@@ -145,8 +153,37 @@ export async function sendTelegramPayloadMessages(params: {
|
||||
const payloadOpts = {
|
||||
...params.baseOpts,
|
||||
quoteText,
|
||||
standardMessage: telegramData?.standardMessage === true,
|
||||
...(params.payload.audioAsVoice === true ? { asVoice: true } : {}),
|
||||
};
|
||||
const singleUseImplicitReply =
|
||||
payloadOpts.standardMessage &&
|
||||
payloadOpts.replyToMessageId != null &&
|
||||
payloadOpts.replyToIdSource !== "explicit" &&
|
||||
payloadOpts.replyToMode != null &&
|
||||
isSingleUseReplyToMode(payloadOpts.replyToMode);
|
||||
let implicitReplyAvailable = true;
|
||||
let deliveredSendCount = 0;
|
||||
const sendWithReplyFanout = async (textLocal: string, options: TelegramSendOpts) => {
|
||||
const effectiveOptions =
|
||||
singleUseImplicitReply && !implicitReplyAvailable
|
||||
? { ...options, replyToMessageId: undefined }
|
||||
: options;
|
||||
let result: Awaited<ReturnType<TelegramSendFn>>;
|
||||
try {
|
||||
result = await params.send(params.to, textLocal, effectiveOptions);
|
||||
} catch (error) {
|
||||
if (deliveredSendCount > 0) {
|
||||
throw markTelegramDeliveryErrorVisible(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
deliveredSendCount += 1;
|
||||
if (singleUseImplicitReply && effectiveOptions.replyToMessageId != null) {
|
||||
implicitReplyAvailable = false;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Telegram allows reply_markup on media; attach buttons only to the first send.
|
||||
return await sendPayloadMediaSequenceOrFallback({
|
||||
@@ -154,12 +191,12 @@ export async function sendTelegramPayloadMessages(params: {
|
||||
mediaUrls,
|
||||
fallbackResult: { messageId: "unknown", chatId: params.to },
|
||||
sendNoMedia: async () =>
|
||||
await params.send(params.to, text, {
|
||||
await sendWithReplyFanout(text, {
|
||||
...payloadOpts,
|
||||
buttons,
|
||||
}),
|
||||
send: async ({ text: textLocal, mediaUrl, isFirst }) =>
|
||||
await params.send(params.to, textLocal, {
|
||||
await sendWithReplyFanout(textLocal, {
|
||||
...payloadOpts,
|
||||
mediaUrl,
|
||||
...(isFirst ? { buttons } : {}),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<boolean>,
|
||||
) => TelegramDeferredAdmissionCallback;
|
||||
registerCancellation: (key: string, cancel: () => Promise<void>) => () => void;
|
||||
cancel: (key: string) => Promise<void>;
|
||||
};
|
||||
|
||||
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<string, Promise<void>>();
|
||||
const cancellations = new Map<string, Set<() => Promise<void>>>();
|
||||
return {
|
||||
reserve: (key, check) => {
|
||||
const previous = tails.get(key) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const completed = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const tail = previous.catch(() => undefined).then(() => completed);
|
||||
tails.set(key, tail);
|
||||
let result: Promise<boolean> | 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<void>>();
|
||||
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()));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<TelegramPeerBotTurn>();
|
||||
|
||||
export function runWithTelegramPeerBotTurn<T>(
|
||||
turn: TelegramPeerBotTurn,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return telegramPeerBotTurn.run(turn, run);
|
||||
}
|
||||
|
||||
export function getTelegramPeerBotTurn(): TelegramPeerBotTurn | undefined {
|
||||
return telegramPeerBotTurn.getStore();
|
||||
}
|
||||
@@ -32,6 +32,10 @@ import {
|
||||
setTelegramSentMessageStoreForTest,
|
||||
wasSentByBot,
|
||||
} from "./sent-message-cache.js";
|
||||
import {
|
||||
resolveTelegramStandardFragmentFrame,
|
||||
stripTelegramStandardFragmentMarker,
|
||||
} from "./standard-text.js";
|
||||
|
||||
installTelegramSendTestHooks();
|
||||
|
||||
@@ -854,6 +858,56 @@ describe("sendMessageTelegram", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("records a completed standard-message batch once without transport framing", async () => {
|
||||
const storePath = `/tmp/openclaw-telegram-send-chunks-${process.pid}-${Date.now()}.json`;
|
||||
const cfg = { session: { store: storePath } };
|
||||
const source = "a".repeat(5_000);
|
||||
let messageId = 1_600;
|
||||
botApi.sendMessage.mockImplementation(async (_chatId, text) => ({
|
||||
message_id: messageId++,
|
||||
date: 1_779_394_740,
|
||||
chat: { id: "-1003966283270", type: "supergroup", title: "Chunk cache" },
|
||||
from: { id: 42, is_bot: true, first_name: "OpenClaw" },
|
||||
text,
|
||||
}));
|
||||
|
||||
await sendMessageTelegram("-1003966283270", source, {
|
||||
cfg,
|
||||
token: "tok",
|
||||
standardMessage: true,
|
||||
});
|
||||
|
||||
const cache = createTelegramMessageCache({
|
||||
scope: resolveTelegramMessageCacheScope(storePath),
|
||||
});
|
||||
await cache.record({
|
||||
accountId: "default",
|
||||
chatId: "-1003966283270",
|
||||
msg: {
|
||||
chat: { id: -1003966283270, type: "supergroup", title: "Chunk cache" },
|
||||
message_id: 1_700,
|
||||
date: 1_779_425_460,
|
||||
text: "next",
|
||||
from: { id: 7, is_bot: false, first_name: "Ada" },
|
||||
},
|
||||
});
|
||||
const context = await buildTelegramConversationContext({
|
||||
cache,
|
||||
accountId: "default",
|
||||
chatId: "-1003966283270",
|
||||
messageId: "1700",
|
||||
replyChainNodes: [],
|
||||
recentLimit: 10,
|
||||
replyTargetWindowSize: 2,
|
||||
});
|
||||
const sentBodies = context
|
||||
.filter((entry) => Number(entry.node.messageId) >= 1_600)
|
||||
.map((entry) => entry.node.body);
|
||||
|
||||
expect(sentBodies).toEqual([source]);
|
||||
expect(sentBodies[0]).not.toContain("\u2060");
|
||||
});
|
||||
|
||||
it("normalizes raw code language HTML before sending", async () => {
|
||||
const chatId = "123";
|
||||
const text = [
|
||||
@@ -922,6 +976,179 @@ describe("sendMessageTelegram", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses standard text messages when requested by a peer-bot turn", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
|
||||
|
||||
await sendMessageTelegram("123", "**hi**", {
|
||||
cfg: { channels: { telegram: { linkPreview: false } } },
|
||||
token: "tok",
|
||||
standardMessage: true,
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
|
||||
expect(botApi.sendMessage).toHaveBeenCalledWith("123", "<b>hi</b>", {
|
||||
link_preview_options: { is_disabled: true },
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-empty plain text when standard formatting renders empty", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
|
||||
|
||||
await sendMessageTelegram("123", ">", {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
standardMessage: true,
|
||||
});
|
||||
|
||||
expect(botApi.sendMessage).toHaveBeenCalledWith("123", ">", {});
|
||||
});
|
||||
|
||||
it("chunks long plain-text fallbacks when standard formatting renders empty", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
|
||||
|
||||
await sendMessageTelegram("123", Array.from({ length: 5_000 }, () => ">").join("\n"), {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
standardMessage: true,
|
||||
});
|
||||
|
||||
expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(botApi.sendMessage.mock.calls.every((call) => String(call[1]).length <= 4096)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses visible-length chunks when standard HTML would split below fragment admission", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
|
||||
|
||||
await sendMessageTelegram("123", `**${"a".repeat(5_000)}**`, {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
standardMessage: true,
|
||||
});
|
||||
|
||||
expect(botApi.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(String(botApi.sendMessage.mock.calls[0]?.[1]).startsWith("\u2060\u200b")).toBe(true);
|
||||
expect(stripTelegramStandardFragmentMarker(String(botApi.sendMessage.mock.calls[0]?.[1]))).toBe(
|
||||
"a".repeat(4062),
|
||||
);
|
||||
expect(botApi.sendMessage.mock.calls[0]?.[2]).not.toHaveProperty("parse_mode");
|
||||
expect(String(botApi.sendMessage.mock.calls[1]?.[1]).startsWith("\u2060\u200d")).toBe(true);
|
||||
expect(stripTelegramStandardFragmentMarker(String(botApi.sendMessage.mock.calls[1]?.[1]))).toBe(
|
||||
"a".repeat(938),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an incomplete standard batch eligible for fallback", async () => {
|
||||
const failure = new Error("second chunk failed");
|
||||
botApi.sendMessage
|
||||
.mockResolvedValueOnce({ message_id: 45, chat: { id: "123" } })
|
||||
.mockRejectedValueOnce(failure);
|
||||
|
||||
await expect(
|
||||
sendMessageTelegram("123", "a".repeat(5_000), {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
standardMessage: true,
|
||||
}),
|
||||
).rejects.toBe(failure);
|
||||
expect(failure).not.toHaveProperty("sentBeforeError");
|
||||
expect(failure).not.toHaveProperty("visibleReplySent");
|
||||
const startFrame = resolveTelegramStandardFragmentFrame(
|
||||
String(botApi.sendMessage.mock.calls[0]?.[1]),
|
||||
);
|
||||
const abortFrame = resolveTelegramStandardFragmentFrame(
|
||||
String(botApi.sendMessage.mock.calls[2]?.[1]),
|
||||
);
|
||||
expect(startFrame?.kind).toBe("start");
|
||||
expect(abortFrame).toEqual({ batchId: startFrame?.batchId, kind: "abort" });
|
||||
});
|
||||
|
||||
it("does not cache an incomplete standard-message prefix", async () => {
|
||||
const storePath = `/tmp/openclaw-telegram-send-incomplete-${process.pid}-${Date.now()}.json`;
|
||||
const cfg = { session: { store: storePath } };
|
||||
const failure = new Error("second chunk failed");
|
||||
botApi.sendMessage
|
||||
.mockResolvedValueOnce({
|
||||
message_id: 45,
|
||||
date: 1_779_394_740,
|
||||
chat: { id: "123", type: "private", first_name: "Peer" },
|
||||
from: { id: 42, is_bot: true, first_name: "OpenClaw" },
|
||||
})
|
||||
.mockRejectedValueOnce(failure);
|
||||
|
||||
await expect(
|
||||
sendMessageTelegram("123", "a".repeat(5_000), {
|
||||
cfg,
|
||||
token: "tok",
|
||||
standardMessage: true,
|
||||
}),
|
||||
).rejects.toBe(failure);
|
||||
|
||||
const cache = createTelegramMessageCache({
|
||||
scope: resolveTelegramMessageCacheScope(storePath),
|
||||
});
|
||||
await cache.record({
|
||||
accountId: "default",
|
||||
chatId: "123",
|
||||
msg: {
|
||||
chat: { id: 123, type: "private", first_name: "Peer" },
|
||||
message_id: 46,
|
||||
date: 1_779_425_460,
|
||||
text: "next",
|
||||
from: { id: 7, is_bot: false, first_name: "Ada" },
|
||||
},
|
||||
});
|
||||
const context = await buildTelegramConversationContext({
|
||||
cache,
|
||||
accountId: "default",
|
||||
chatId: "123",
|
||||
messageId: "46",
|
||||
replyChainNodes: [],
|
||||
recentLimit: 10,
|
||||
replyTargetWindowSize: 2,
|
||||
});
|
||||
|
||||
expect(context.map((entry) => entry.node.messageId)).not.toContain("45");
|
||||
expect(context.map((entry) => entry.node.body).join("\n")).not.toContain("\u2060");
|
||||
});
|
||||
|
||||
it("preserves single-use reply targets across one standard-message payload", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
|
||||
|
||||
await sendMessageTelegram("123", "a".repeat(5_000), {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
standardMessage: true,
|
||||
replyToMessageId: 42,
|
||||
replyToMode: "first",
|
||||
});
|
||||
|
||||
expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(botApi.sendMessage.mock.calls.every((call) => call[2]?.reply_to_message_id === 42)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves explicit reply targets across standard-message chunks", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
|
||||
|
||||
await sendMessageTelegram("123", "a".repeat(5_000), {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
standardMessage: true,
|
||||
replyToMessageId: 42,
|
||||
replyToIdSource: "explicit",
|
||||
replyToMode: "first",
|
||||
});
|
||||
|
||||
expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(botApi.sendMessage.mock.calls.every((call) => call[2]?.reply_to_message_id === 42)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps complex markdown raw for rich message text", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 46, chat: { id: "123" } });
|
||||
const markdown = [
|
||||
@@ -1502,6 +1729,107 @@ describe("sendMessageTelegram", () => {
|
||||
expect(res.messageId).toBe("71");
|
||||
});
|
||||
|
||||
it("marks follow-up text failures after visible media as partially delivered", async () => {
|
||||
const chatId = "123";
|
||||
const sendPhoto = vi.fn().mockResolvedValue({ message_id: 70, chat: { id: chatId } });
|
||||
const sendMessage = vi.fn().mockRejectedValue(new Error("follow-up failed"));
|
||||
const api = { sendPhoto, sendMessage } as unknown as {
|
||||
sendPhoto: typeof sendPhoto;
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
mockLoadedMedia({
|
||||
buffer: Buffer.from("fake-image"),
|
||||
contentType: "image/jpeg",
|
||||
fileName: "photo.jpg",
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendMessageTelegram(chatId, "A".repeat(1100), {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
api,
|
||||
mediaUrl: "https://example.com/photo.jpg",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
message: "follow-up failed",
|
||||
sentBeforeError: true,
|
||||
visibleReplySent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a failed standard media follow-up partial after visible media", async () => {
|
||||
const chatId = "123";
|
||||
const sendPhoto = vi.fn().mockResolvedValue({ message_id: 70, chat: { id: chatId } });
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ message_id: 71, chat: { id: chatId } })
|
||||
.mockRejectedValueOnce(new Error("second follow-up fragment failed"))
|
||||
.mockResolvedValueOnce({ message_id: 72, chat: { id: chatId } });
|
||||
const api = { sendPhoto, sendMessage } as unknown as {
|
||||
sendPhoto: typeof sendPhoto;
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
mockLoadedMedia({
|
||||
buffer: Buffer.from("fake-image"),
|
||||
contentType: "image/jpeg",
|
||||
fileName: "photo.jpg",
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendMessageTelegram(chatId, "A".repeat(6_000), {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
api,
|
||||
mediaUrl: "https://example.com/photo.jpg",
|
||||
standardMessage: true,
|
||||
replyToMessageId: 42,
|
||||
replyToMode: "first",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
message: "second follow-up fragment failed",
|
||||
sentBeforeError: true,
|
||||
visibleReplySent: true,
|
||||
});
|
||||
expect(sendPhoto).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledTimes(3);
|
||||
expect(resolveTelegramStandardFragmentFrame(String(sendMessage.mock.calls[2]?.[1]))?.kind).toBe(
|
||||
"abort",
|
||||
);
|
||||
expect(sendMessage.mock.calls[2]?.[2]).toMatchObject({ reply_to_message_id: 42 });
|
||||
});
|
||||
|
||||
it("keeps peer reply targeting on media follow-up text", async () => {
|
||||
const chatId = "123";
|
||||
const sendPhoto = vi.fn().mockResolvedValue({ message_id: 70, chat: { id: chatId } });
|
||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 71, chat: { id: chatId } });
|
||||
const api = { sendPhoto, sendMessage } as unknown as {
|
||||
sendPhoto: typeof sendPhoto;
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
mockLoadedMedia({
|
||||
buffer: Buffer.from("fake-image"),
|
||||
contentType: "image/jpeg",
|
||||
fileName: "photo.jpg",
|
||||
});
|
||||
|
||||
await sendMessageTelegram(chatId, "A".repeat(1_100), {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
api,
|
||||
mediaUrl: "https://example.com/photo.jpg",
|
||||
standardMessage: true,
|
||||
replyToMessageId: 42,
|
||||
replyToMode: "first",
|
||||
});
|
||||
|
||||
expect(firstMockCall(sendPhoto, "send photo call")[2]).toMatchObject({
|
||||
reply_to_message_id: 42,
|
||||
});
|
||||
expect(firstMockCall(sendMessage, "send message call")[2]).toMatchObject({
|
||||
reply_to_message_id: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("chunks long default markdown media follow-up text", async () => {
|
||||
const chatId = "123";
|
||||
const longText = `**${"A".repeat(5000)}**`;
|
||||
|
||||
+148
-27
@@ -3,6 +3,7 @@ import * as grammy from "grammy";
|
||||
import { type ApiClientOptions, Bot, HttpError } from "grammy";
|
||||
import type { ReactionType, ReactionTypeEmoji } from "grammy/types";
|
||||
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
|
||||
import type { ReplyToMode } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
import { formatUncaughtError } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
|
||||
@@ -20,6 +21,7 @@ import { buildTypingThreadParams } from "./bot/helpers.js";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
import { splitTelegramCaption } from "./caption.js";
|
||||
import { asTelegramClientFetch, createTelegramClientFetch } from "./client-fetch.js";
|
||||
import { markTelegramDeliveryErrorVisible } from "./delivery-error.js";
|
||||
import { resolveTelegramTransport } from "./fetch.js";
|
||||
import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "./format.js";
|
||||
import { buildInlineKeyboard } from "./inline-keyboard.js";
|
||||
@@ -61,6 +63,11 @@ import {
|
||||
resolveMarkdownTableMode,
|
||||
} from "./send.runtime.js";
|
||||
import { recordSentMessage } from "./sent-message-cache.js";
|
||||
import {
|
||||
buildTelegramStandardFragmentAbort,
|
||||
buildTelegramStandardTextChunks,
|
||||
stripTelegramStandardFragmentMarker,
|
||||
} from "./standard-text.js";
|
||||
import { maybePersistResolvedTelegramTarget } from "./target-writeback.js";
|
||||
import {
|
||||
normalizeTelegramChatId,
|
||||
@@ -96,6 +103,8 @@ type TelegramSendOpts = {
|
||||
api?: TelegramApiOverride;
|
||||
retry?: RetryConfig;
|
||||
textMode?: "markdown" | "html";
|
||||
/** Use the standard Bot API text method instead of rich messages. */
|
||||
standardMessage?: boolean;
|
||||
/** Send audio as voice message instead of audio file. Defaults to false. */
|
||||
asVoice?: boolean;
|
||||
/** Send video as video note instead of regular video. Defaults to false. */
|
||||
@@ -104,6 +113,10 @@ type TelegramSendOpts = {
|
||||
silent?: boolean;
|
||||
/** Message ID to reply to (for threading) */
|
||||
replyToMessageId?: number;
|
||||
/** Controls whether the reply target applies once or to every chunk. */
|
||||
replyToMode?: ReplyToMode;
|
||||
/** Distinguishes explicit agent targets from ambient reply-mode targets. */
|
||||
replyToIdSource?: "explicit" | "implicit";
|
||||
/** Quote text for Telegram reply_parameters. */
|
||||
quoteText?: string;
|
||||
/** Forum topic thread ID (for forum supergroups) */
|
||||
@@ -621,12 +634,47 @@ export async function sendMessageTelegram(
|
||||
|
||||
type TelegramTextChunk = {
|
||||
text: string;
|
||||
htmlText?: string;
|
||||
plainText?: string;
|
||||
};
|
||||
|
||||
const sendTelegramTextChunk = async (
|
||||
chunk: TelegramTextChunk,
|
||||
params?: TelegramRichMessageContextParams,
|
||||
params?: TelegramRichMessageContextParams | TelegramThreadScopedParams,
|
||||
) => {
|
||||
if (opts.standardMessage === true) {
|
||||
const standardParams = {
|
||||
...params,
|
||||
...(opts.silent === true ? { disable_notification: true } : {}),
|
||||
...(account.config.linkPreview === false
|
||||
? { link_preview_options: { is_disabled: true } }
|
||||
: {}),
|
||||
};
|
||||
const plainText = chunk.plainText ?? telegramHtmlToPlainTextFallback(chunk.htmlText ?? "");
|
||||
if (!chunk.htmlText) {
|
||||
const result = await requestWithChatNotFound(
|
||||
() => api.sendMessage(chatId, plainText || chunk.text, standardParams),
|
||||
"message",
|
||||
);
|
||||
return { result, acceptedParams: params };
|
||||
}
|
||||
const result = await withTelegramHtmlParseFallback({
|
||||
label: "message",
|
||||
verbose: opts.verbose,
|
||||
requestHtml: (label) =>
|
||||
requestWithChatNotFound(
|
||||
() =>
|
||||
api.sendMessage(chatId, chunk.htmlText ?? chunk.text, {
|
||||
...standardParams,
|
||||
parse_mode: "HTML",
|
||||
}),
|
||||
label,
|
||||
),
|
||||
requestPlain: (label) =>
|
||||
requestWithChatNotFound(() => api.sendMessage(chatId, plainText, standardParams), label),
|
||||
});
|
||||
return { result, acceptedParams: params };
|
||||
}
|
||||
const richRawApi = getTelegramRichRawApi(api);
|
||||
const richParams = {
|
||||
...params,
|
||||
@@ -644,55 +692,111 @@ export async function sendMessageTelegram(
|
||||
return { result, acceptedParams: params };
|
||||
};
|
||||
|
||||
const buildTextParams = (isLastChunk: boolean) =>
|
||||
hasRichThreadParams || (isLastChunk && replyMarkup)
|
||||
? {
|
||||
...richThreadParams,
|
||||
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const buildTextParams = (isLastChunk: boolean) => {
|
||||
return opts.standardMessage === true
|
||||
? hasThreadParams || (isLastChunk && replyMarkup)
|
||||
? {
|
||||
...threadParams,
|
||||
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
}
|
||||
: undefined
|
||||
: hasRichThreadParams || (isLastChunk && replyMarkup)
|
||||
? {
|
||||
...richThreadParams,
|
||||
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const sendTelegramTextChunks = async (
|
||||
chunks: TelegramTextChunk[],
|
||||
context: string,
|
||||
priorVisibleDelivery = false,
|
||||
): Promise<{ messageId: string; chatId: string }> => {
|
||||
let lastMessageId = "";
|
||||
let lastChatId = chatId;
|
||||
let lastAcceptedParams: TelegramThreadScopedParams | undefined;
|
||||
let lastContextMessage: TelegramMessageLike | undefined;
|
||||
let lastContextMessageId: number | undefined;
|
||||
let sentChunkCount = 0;
|
||||
const isFramedStandardBatch = opts.standardMessage === true && chunks.length > 1;
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
const chunk = chunks[index];
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
const { result: res, acceptedParams } = await sendTelegramTextChunk(
|
||||
chunk,
|
||||
buildTextParams(index === chunks.length - 1),
|
||||
);
|
||||
const messageId = resolveTelegramMessageIdOrThrow(res, context);
|
||||
recordSentMessage(chatId, messageId, cfg);
|
||||
try {
|
||||
const { result: res, acceptedParams } = await sendTelegramTextChunk(
|
||||
chunk,
|
||||
buildTextParams(index === chunks.length - 1),
|
||||
);
|
||||
sentChunkCount += 1;
|
||||
const messageId = resolveTelegramMessageIdOrThrow(res, context);
|
||||
recordSentMessage(chatId, messageId, cfg);
|
||||
if (isFramedStandardBatch) {
|
||||
lastContextMessage = res;
|
||||
lastContextMessageId = messageId;
|
||||
} else {
|
||||
await recordOutboundMessageForPromptContext({
|
||||
cfg,
|
||||
account,
|
||||
chatId,
|
||||
message: res,
|
||||
messageId,
|
||||
text: chunk.text,
|
||||
...(acceptedParams?.message_thread_id !== undefined
|
||||
? { messageThreadId: acceptedParams.message_thread_id }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
lastMessageId = String(messageId);
|
||||
lastChatId = String(res?.chat?.id ?? chatId);
|
||||
lastAcceptedParams = acceptedParams;
|
||||
} catch (error) {
|
||||
if (sentChunkCount === 0) {
|
||||
throw error;
|
||||
}
|
||||
if (opts.standardMessage === true && chunks.length > 1) {
|
||||
// Framed prefixes are transport-only: the peer drops them unless the
|
||||
// end frame arrives. Retire the batch before an unframed fallback.
|
||||
const abortText = buildTelegramStandardFragmentAbort(chunks[0]?.text ?? "");
|
||||
if (abortText) {
|
||||
await sendTelegramTextChunk(
|
||||
{ text: abortText, plainText: abortText },
|
||||
buildTextParams(false),
|
||||
).catch(() => undefined);
|
||||
}
|
||||
if (!priorVisibleDelivery) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw markTelegramDeliveryErrorVisible(error);
|
||||
}
|
||||
}
|
||||
if (isFramedStandardBatch && lastContextMessage && lastContextMessageId != null) {
|
||||
const logicalText = chunks
|
||||
.map((chunk) => stripTelegramStandardFragmentMarker(chunk.plainText ?? chunk.text))
|
||||
.join("");
|
||||
// Transport is complete once the end frame lands. Context persistence must
|
||||
// not turn that visible logical send into a retryable delivery failure.
|
||||
await recordOutboundMessageForPromptContext({
|
||||
cfg,
|
||||
account,
|
||||
chatId,
|
||||
message: res,
|
||||
messageId,
|
||||
text: chunk.text,
|
||||
...(acceptedParams?.message_thread_id !== undefined
|
||||
? { messageThreadId: acceptedParams.message_thread_id }
|
||||
message: { ...lastContextMessage, text: logicalText },
|
||||
messageId: lastContextMessageId,
|
||||
text: logicalText,
|
||||
...(lastAcceptedParams?.message_thread_id !== undefined
|
||||
? { messageThreadId: lastAcceptedParams.message_thread_id }
|
||||
: {}),
|
||||
});
|
||||
lastMessageId = String(messageId);
|
||||
lastChatId = String(res?.chat?.id ?? chatId);
|
||||
lastAcceptedParams = acceptedParams;
|
||||
sentChunkCount += 1;
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
if (lastMessageId) {
|
||||
logTelegramOutboundSendOk({
|
||||
accountId: account.accountId,
|
||||
chatId: lastChatId,
|
||||
messageId: lastMessageId,
|
||||
operation: "sendRichMessage",
|
||||
operation: opts.standardMessage === true ? "sendMessage" : "sendRichMessage",
|
||||
deliveryKind: "text",
|
||||
messageThreadId: lastAcceptedParams?.message_thread_id,
|
||||
replyToMessageId: opts.replyToMessageId,
|
||||
@@ -704,6 +808,15 @@ export async function sendMessageTelegram(
|
||||
};
|
||||
|
||||
const buildChunkedTextPlan = (rawText: string): TelegramTextChunk[] => {
|
||||
if (opts.standardMessage === true) {
|
||||
return buildTelegramStandardTextChunks(rawText, { tableMode }).map((chunk) =>
|
||||
Object.assign(
|
||||
{ text: chunk.plainText },
|
||||
chunk.htmlText ? { htmlText: chunk.htmlText } : {},
|
||||
{ plainText: chunk.plainText },
|
||||
),
|
||||
);
|
||||
}
|
||||
return splitTelegramRichTextChunks({
|
||||
text: rawText,
|
||||
textLimit,
|
||||
@@ -935,8 +1048,16 @@ export async function sendMessageTelegram(
|
||||
// If text was too long for a caption, send it as a separate follow-up message.
|
||||
// Use HTML conversion so markdown renders like captions.
|
||||
if (needsSeparateText && followUpText) {
|
||||
const textResult = await sendChunkedText(followUpText, "text follow-up send");
|
||||
return { messageId: textResult.messageId, chatId: resolvedChatId };
|
||||
try {
|
||||
const textResult = await sendTelegramTextChunks(
|
||||
buildChunkedTextPlan(followUpText),
|
||||
"text follow-up send",
|
||||
true,
|
||||
);
|
||||
return { messageId: textResult.messageId, chatId: resolvedChatId };
|
||||
} catch (error) {
|
||||
throw markTelegramDeliveryErrorVisible(error);
|
||||
}
|
||||
}
|
||||
|
||||
return { messageId: String(mediaMessageId), chatId: resolvedChatId };
|
||||
|
||||
@@ -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;
|
||||
@@ -241,6 +241,7 @@ export type AgentRuntimeReplyPayload = {
|
||||
question: string;
|
||||
};
|
||||
replyToId?: string;
|
||||
replyToIdSource?: "explicit" | "implicit";
|
||||
replyToTag?: boolean;
|
||||
replyToCurrent?: boolean;
|
||||
audioAsVoice?: boolean;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReplyToMode } from "../config/types.js";
|
||||
/** Public option types for reply generation callbacks, streaming, and delivery policy. */
|
||||
import type { ImageContent } from "../llm/types.js";
|
||||
import type { PromptImageOrderEntry } from "../media/prompt-image-order.js";
|
||||
@@ -214,6 +215,14 @@ export type GetReplyOptions = {
|
||||
queuedDeliveryCorrelations?: QueuedReplyDeliveryCorrelation[];
|
||||
/** Tracks ownership transfer when this turn later drains as a queued followup. */
|
||||
queuedFollowupLifecycle?: QueuedReplyLifecycle;
|
||||
/** Applies source-channel delivery metadata when a queued follow-up later drains. */
|
||||
queuedDeliveryPayloadTransform?: (payload: ReplyPayload) => ReplyPayload;
|
||||
/** Preserves source-channel reply fan-out policy for queued delivery. */
|
||||
queuedDeliveryReplyToMode?: ReplyToMode;
|
||||
/** Commits source-channel delivery state after a queued payload is visibly routed. */
|
||||
queuedDeliveryPayloadDidDeliver?: (payload: ReplyPayload) => void;
|
||||
/** Re-establishes source-owned async context while a queued follow-up drains. */
|
||||
queuedExecutionContext?: <T>(run: () => Promise<T>) => Promise<T>;
|
||||
/** 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. */
|
||||
|
||||
@@ -28,6 +28,8 @@ export type ReplyPayload = {
|
||||
question: string;
|
||||
};
|
||||
replyToId?: string;
|
||||
/** Internal reply-policy provenance; implicit ids honor single-use reply modes. */
|
||||
replyToIdSource?: "explicit" | "implicit";
|
||||
replyToTag?: boolean;
|
||||
/** True when [[reply_to_current]] was present but not yet mapped to a message id. */
|
||||
replyToCurrent?: boolean;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -19,10 +19,13 @@ const deliveryMocks = vi.hoisted(() => ({
|
||||
_params: unknown,
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
delivered?: boolean;
|
||||
error?: string;
|
||||
messageId?: string;
|
||||
partialFailure?: boolean;
|
||||
suppressed?: boolean;
|
||||
reason?: string;
|
||||
}> => ({ ok: true, messageId: "mock-message" }),
|
||||
}> => ({ ok: true, delivered: true, messageId: "mock-message" }),
|
||||
),
|
||||
runMessageAction: vi.fn(async (_params: unknown) => ({ ok: true as const })),
|
||||
}));
|
||||
@@ -153,7 +156,11 @@ async function expectVisibleChatBlockRoutesToAccount(
|
||||
describe("createAcpDispatchDeliveryCoordinator", () => {
|
||||
beforeEach(() => {
|
||||
deliveryMocks.routeReply.mockClear();
|
||||
deliveryMocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock-message" });
|
||||
deliveryMocks.routeReply.mockResolvedValue({
|
||||
ok: true,
|
||||
delivered: true,
|
||||
messageId: "mock-message",
|
||||
});
|
||||
deliveryMocks.runMessageAction.mockClear();
|
||||
deliveryMocks.runMessageAction.mockResolvedValue({ ok: true as const });
|
||||
channelPluginMocks.getChannelPlugin.mockClear();
|
||||
@@ -833,6 +840,36 @@ describe("createAcpDispatchDeliveryCoordinator", () => {
|
||||
expect(coordinator.getRoutedCounts().block).toBe(1);
|
||||
});
|
||||
|
||||
it("records partial routed block delivery as visible and failed without fallback", async () => {
|
||||
deliveryMocks.routeReply.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
delivered: true,
|
||||
partialFailure: true,
|
||||
error: "second chunk failed",
|
||||
messageId: "visible-1",
|
||||
});
|
||||
const coordinator = createVisibleChatAcpCoordinator(createAcpTestConfig());
|
||||
|
||||
const delivered = await coordinator.deliver("block", { text: "hello" }, { skipTts: true });
|
||||
|
||||
expect(delivered).toBe(true);
|
||||
expect(coordinator.hasDeliveredVisibleText()).toBe(true);
|
||||
expect(coordinator.hasFailedVisibleTextDelivery()).toBe(true);
|
||||
expect(coordinator.getRoutedCounts().block).toBe(1);
|
||||
});
|
||||
|
||||
it("does not count a successful routed no-op as ACP delivery", async () => {
|
||||
deliveryMocks.routeReply.mockResolvedValueOnce({ ok: true, delivered: false });
|
||||
const coordinator = createVisibleChatAcpCoordinator(createAcpTestConfig());
|
||||
|
||||
const delivered = await coordinator.deliver("block", { text: "hello" }, { skipTts: true });
|
||||
|
||||
expect(delivered).toBe(false);
|
||||
expect(coordinator.hasDeliveredVisibleText()).toBe(false);
|
||||
expect(coordinator.hasFailedVisibleTextDelivery()).toBe(false);
|
||||
expect(coordinator.getRoutedCounts().block).toBe(0);
|
||||
});
|
||||
|
||||
it("treats hook-suppressed routed ACP block text as handled", async () => {
|
||||
deliveryMocks.routeReply.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
||||
@@ -154,6 +154,7 @@ type AcpDispatchDeliveryState = {
|
||||
deliveredFinalReply: boolean;
|
||||
deliveredVisibleText: boolean;
|
||||
failedVisibleTextDelivery: boolean;
|
||||
requiresVisibleTextFallback: boolean;
|
||||
queuedDirectVisibleTextDeliveries: number;
|
||||
settledDirectVisibleText: boolean;
|
||||
routedCounts: Record<ReplyDispatchKind, number>;
|
||||
@@ -176,6 +177,7 @@ export type AcpDispatchDeliveryCoordinator = {
|
||||
hasDeliveredFinalReply: () => boolean;
|
||||
hasDeliveredVisibleText: () => boolean;
|
||||
hasFailedVisibleTextDelivery: () => boolean;
|
||||
requiresVisibleTextFallback: () => boolean;
|
||||
getRoutedCounts: () => Record<ReplyDispatchKind, number>;
|
||||
applyRoutedCounts: (counts: Record<ReplyDispatchKind, number>) => void;
|
||||
};
|
||||
@@ -232,6 +234,7 @@ export function createAcpDispatchDeliveryCoordinator(params: {
|
||||
deliveredFinalReply: false,
|
||||
deliveredVisibleText: false,
|
||||
failedVisibleTextDelivery: false,
|
||||
requiresVisibleTextFallback: false,
|
||||
queuedDirectVisibleTextDeliveries: 0,
|
||||
settledDirectVisibleText: false,
|
||||
routedCounts: {
|
||||
@@ -263,6 +266,7 @@ export function createAcpDispatchDeliveryCoordinator(params: {
|
||||
const failedVisibleCount = failedCounts.block + failedCounts.final;
|
||||
if (failedVisibleCount > 0) {
|
||||
state.failedVisibleTextDelivery = true;
|
||||
state.requiresVisibleTextFallback = true;
|
||||
}
|
||||
if (state.queuedDirectVisibleTextDeliveries > failedVisibleCount) {
|
||||
state.deliveredVisibleText = true;
|
||||
@@ -438,11 +442,13 @@ export function createAcpDispatchDeliveryCoordinator(params: {
|
||||
if (!result.ok) {
|
||||
if (tracksVisibleText) {
|
||||
state.failedVisibleTextDelivery = true;
|
||||
if (!result.delivered) {
|
||||
state.requiresVisibleTextFallback = true;
|
||||
}
|
||||
}
|
||||
logVerbose(
|
||||
`dispatch-acp: route-reply (acp/${kind}) failed: ${result.error ?? "unknown error"}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (result.suppressed) {
|
||||
if (kind === "final") {
|
||||
@@ -453,6 +459,9 @@ export function createAcpDispatchDeliveryCoordinator(params: {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!result.delivered) {
|
||||
return false;
|
||||
}
|
||||
if (kind === "tool" && meta?.toolCallId && result.messageId) {
|
||||
state.toolMessageByCallId.set(meta.toolCallId, {
|
||||
channel: params.originatingChannel,
|
||||
@@ -496,6 +505,7 @@ export function createAcpDispatchDeliveryCoordinator(params: {
|
||||
state.settledDirectVisibleText = false;
|
||||
} else if (!delivered && tracksVisibleText) {
|
||||
state.failedVisibleTextDelivery = true;
|
||||
state.requiresVisibleTextFallback = true;
|
||||
}
|
||||
if (kind === "block" && delivered) {
|
||||
hasPendingDirectBlockReplyDelivery = true;
|
||||
@@ -515,6 +525,7 @@ export function createAcpDispatchDeliveryCoordinator(params: {
|
||||
hasDeliveredFinalReply: () => state.deliveredFinalReply,
|
||||
hasDeliveredVisibleText: () => state.deliveredVisibleText,
|
||||
hasFailedVisibleTextDelivery: () => state.failedVisibleTextDelivery,
|
||||
requiresVisibleTextFallback: () => state.requiresVisibleTextFallback,
|
||||
getRoutedCounts: () => ({ ...state.routedCounts }),
|
||||
applyRoutedCounts: (counts) => {
|
||||
counts.tool += state.routedCounts.tool;
|
||||
|
||||
@@ -41,8 +41,13 @@ const policyMocks = vi.hoisted(() => ({
|
||||
|
||||
const routeMocks = vi.hoisted(() => ({
|
||||
routeReply: vi.fn<
|
||||
(_params: unknown) => Promise<{ ok: true; messageId: string } | { ok: false; error: string }>
|
||||
>(async () => ({ ok: true, messageId: "mock" })),
|
||||
(
|
||||
_params: unknown,
|
||||
) => Promise<
|
||||
| { ok: true; delivered: true; messageId: string }
|
||||
| { ok: false; error: string; delivered?: boolean; partialFailure?: boolean }
|
||||
>
|
||||
>(async () => ({ ok: true, delivered: true, messageId: "mock" })),
|
||||
}));
|
||||
|
||||
const channelPluginMocks = vi.hoisted(() => ({
|
||||
@@ -445,7 +450,7 @@ describe("tryDispatchAcpReply", () => {
|
||||
policyMocks.resolveAcpAgentPolicyError.mockReset();
|
||||
policyMocks.resolveAcpAgentPolicyError.mockReturnValue(null);
|
||||
routeMocks.routeReply.mockReset();
|
||||
routeMocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" });
|
||||
routeMocks.routeReply.mockResolvedValue({ ok: true, delivered: true, messageId: "mock" });
|
||||
channelPluginMocks.getChannelPlugin.mockClear();
|
||||
messageActionMocks.runMessageAction.mockReset();
|
||||
messageActionMocks.runMessageAction.mockResolvedValue({ ok: true as const });
|
||||
@@ -575,7 +580,11 @@ describe("tryDispatchAcpReply", () => {
|
||||
it("edits ACP tool lifecycle updates in place when supported", async () => {
|
||||
setReadyAcpResolution();
|
||||
mockToolLifecycleTurn("call-1");
|
||||
routeMocks.routeReply.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-1" });
|
||||
routeMocks.routeReply.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
delivered: true,
|
||||
messageId: "tool-msg-1",
|
||||
});
|
||||
|
||||
const { dispatcher } = createDispatcher();
|
||||
await runDispatch({
|
||||
@@ -596,8 +605,12 @@ describe("tryDispatchAcpReply", () => {
|
||||
setReadyAcpResolution();
|
||||
mockToolLifecycleTurn("call-2");
|
||||
routeMocks.routeReply
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2" })
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2-fallback" });
|
||||
.mockResolvedValueOnce({ ok: true, delivered: true, messageId: "tool-msg-2" })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
delivered: true,
|
||||
messageId: "tool-msg-2-fallback",
|
||||
});
|
||||
messageActionMocks.runMessageAction.mockRejectedValueOnce(new Error("edit unsupported"));
|
||||
|
||||
const { dispatcher } = createDispatcher();
|
||||
@@ -1710,6 +1723,28 @@ describe("tryDispatchAcpReply", () => {
|
||||
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not replay a routed ACP block after partial visible delivery", async () => {
|
||||
setReadyAcpResolution();
|
||||
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
||||
routeMocks.routeReply.mockResolvedValue({
|
||||
ok: false,
|
||||
delivered: true,
|
||||
partialFailure: true,
|
||||
error: "second chunk failed",
|
||||
});
|
||||
mockRoutedTextTurn("partially visible block");
|
||||
const { dispatcher } = createDispatcher();
|
||||
|
||||
await runDispatch({
|
||||
bodyForAgent: "run acp",
|
||||
dispatcher,
|
||||
shouldRouteToOriginating: true,
|
||||
});
|
||||
|
||||
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
|
||||
expect(routePayload().text).toBe("partially visible block");
|
||||
});
|
||||
|
||||
it("routes default ACP text as one final reply to Discord", async () => {
|
||||
setReadyAcpResolution();
|
||||
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
||||
|
||||
@@ -266,7 +266,7 @@ async function finalizeAcpTurnOutput(params: {
|
||||
}): Promise<boolean> {
|
||||
await params.delivery.settleVisibleText();
|
||||
let queuedFinal =
|
||||
params.delivery.hasDeliveredVisibleText() && !params.delivery.hasFailedVisibleTextDelivery();
|
||||
params.delivery.hasDeliveredVisibleText() && !params.delivery.requiresVisibleTextFallback();
|
||||
const ttsMode = resolveConfiguredTtsMode(params.cfg, {
|
||||
agentId: params.agentId,
|
||||
channelId: params.ttsChannel,
|
||||
@@ -328,7 +328,7 @@ async function finalizeAcpTurnOutput(params: {
|
||||
accumulatedVisibleBlockText.trim().length > 0 &&
|
||||
!finalMediaDelivered &&
|
||||
!params.delivery.hasDeliveredFinalReply() &&
|
||||
(!params.delivery.hasDeliveredVisibleText() || params.delivery.hasFailedVisibleTextDelivery());
|
||||
(!params.delivery.hasDeliveredVisibleText() || params.delivery.requiresVisibleTextFallback());
|
||||
if (shouldDeliverTextFallback) {
|
||||
const delivered = await params.delivery.deliver(
|
||||
"final",
|
||||
|
||||
@@ -56,7 +56,11 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
|
||||
clearAgentHarnesses();
|
||||
setDiscordTestRegistry();
|
||||
resetInboundDedupe();
|
||||
mocks.routeReply.mockReset().mockResolvedValue({ ok: true, messageId: "mock" });
|
||||
mocks.routeReply.mockReset().mockResolvedValue({
|
||||
ok: true,
|
||||
delivered: true,
|
||||
messageId: "mock",
|
||||
});
|
||||
mocks.tryFastAbortFromMessage.mockReset().mockResolvedValue({
|
||||
handled: false,
|
||||
aborted: false,
|
||||
@@ -194,7 +198,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
|
||||
sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({
|
||||
existing: sessionStoreMocks.currentEntry,
|
||||
});
|
||||
mocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" });
|
||||
mocks.routeReply.mockResolvedValue({ ok: true, delivered: true, messageId: "mock" });
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: createHookCtx(),
|
||||
|
||||
@@ -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<RouteReplyResult>>(async () => ({
|
||||
ok: true,
|
||||
delivered: true,
|
||||
messageId: "mock",
|
||||
})),
|
||||
tryFastAbortFromMessage: vi.fn<() => Promise<AbortResult>>(async () => ({
|
||||
handled: false,
|
||||
aborted: false,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type ReplyDispatchBeforeDeliver,
|
||||
type ReplyDispatcher,
|
||||
} from "./reply-dispatcher.js";
|
||||
import type { RouteReplyResult } from "./route-reply.js";
|
||||
import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js";
|
||||
import { buildTestCtx } from "./test-ctx.js";
|
||||
|
||||
@@ -52,7 +53,11 @@ type ResolveInboundConversationParams = Parameters<
|
||||
>[0];
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
routeReply: vi.fn(async (_params: unknown) => ({ ok: true, messageId: "mock" })),
|
||||
routeReply: vi.fn<(_params: unknown) => Promise<RouteReplyResult>>(async () => ({
|
||||
ok: true,
|
||||
delivered: true,
|
||||
messageId: "mock",
|
||||
})),
|
||||
tryFastAbortFromMessage: vi.fn<() => Promise<AbortResult>>(async () => ({
|
||||
handled: false,
|
||||
aborted: false,
|
||||
@@ -965,7 +970,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
replyRunTesting.resetReplyRunRegistry();
|
||||
resetInboundDedupe();
|
||||
mocks.routeReply.mockReset();
|
||||
mocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" });
|
||||
mocks.routeReply.mockResolvedValue({ ok: true, delivered: true, messageId: "mock" });
|
||||
acpMocks.listAcpSessionEntries.mockReset().mockResolvedValue([]);
|
||||
diagnosticMocks.logMessageQueued.mockClear();
|
||||
diagnosticMocks.logMessageProcessed.mockClear();
|
||||
@@ -1379,6 +1384,83 @@ describe("dispatchReplyFromConfig", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a partially delivered routed final as visible and handled", async () => {
|
||||
setNoAbort();
|
||||
const dispatcher = createDispatcher();
|
||||
mocks.routeReply.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
delivered: true,
|
||||
error: "second chunk failed",
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:999",
|
||||
SessionKey: "agent:main:telegram:group:999",
|
||||
}),
|
||||
cfg: emptyConfig,
|
||||
dispatcher,
|
||||
replyResolver: async () => ({ text: "Partially delivered routed reply" }),
|
||||
});
|
||||
|
||||
expect(result.queuedFinal).toBe(true);
|
||||
expect(result.counts.final).toBe(1);
|
||||
expect(result.noVisibleReplyFallbackEligible).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps an explicit routed no-op eligible for visible fallback", async () => {
|
||||
setNoAbort();
|
||||
const dispatcher = createDispatcher();
|
||||
mocks.routeReply.mockResolvedValueOnce({ ok: true, delivered: false });
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:999",
|
||||
SessionKey: "agent:main:telegram:group:999",
|
||||
}),
|
||||
cfg: emptyConfig,
|
||||
dispatcher,
|
||||
replyResolver: async () => ({ text: "Normalized no-op" }),
|
||||
});
|
||||
|
||||
expect(result.queuedFinal).toBe(false);
|
||||
expect(result.counts.final).toBe(0);
|
||||
expect(result.noVisibleReplyFallbackEligible).toBe(true);
|
||||
});
|
||||
|
||||
it("treats intentional routed suppression as handled but not visible", async () => {
|
||||
setNoAbort();
|
||||
const dispatcher = createDispatcher();
|
||||
mocks.routeReply.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
delivered: false,
|
||||
suppressed: true,
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:999",
|
||||
SessionKey: "agent:main:telegram:group:999",
|
||||
}),
|
||||
cfg: emptyConfig,
|
||||
dispatcher,
|
||||
replyResolver: async () => ({ text: "Suppressed by hook" }),
|
||||
});
|
||||
|
||||
expect(result.queuedFinal).toBe(true);
|
||||
expect(result.counts.final).toBe(0);
|
||||
expect(result.noVisibleReplyFallbackEligible).toBeUndefined();
|
||||
});
|
||||
|
||||
it("mirrors the delivered ownerless Slack text after dispatcher hook rewrites", async () => {
|
||||
setNoAbort();
|
||||
const dispatcher = createDispatcher();
|
||||
@@ -7503,7 +7585,7 @@ describe("before_dispatch hook", () => {
|
||||
beforeEach(() => {
|
||||
resetInboundDedupe();
|
||||
mocks.routeReply.mockReset();
|
||||
mocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" });
|
||||
mocks.routeReply.mockResolvedValue({ ok: true, delivered: true, messageId: "mock" });
|
||||
threadInfoMocks.parseSessionThreadInfo.mockReset();
|
||||
threadInfoMocks.parseSessionThreadInfo.mockImplementation(parseGenericThreadSessionInfo);
|
||||
ttsMocks.state.synthesizeFinalAudio = false;
|
||||
|
||||
@@ -1661,8 +1661,16 @@ export async function dispatchReplyFromConfig(
|
||||
});
|
||||
};
|
||||
|
||||
const isRoutedReplyDelivered = (result: { ok: boolean; suppressed?: boolean }) =>
|
||||
result.ok && result.suppressed !== true;
|
||||
const isRoutedReplyDelivered = (result: {
|
||||
ok: boolean;
|
||||
delivered?: boolean;
|
||||
suppressed?: boolean;
|
||||
}) => result.delivered === true;
|
||||
const isRoutedReplyHandled = (result: {
|
||||
ok: boolean;
|
||||
delivered?: boolean;
|
||||
suppressed?: boolean;
|
||||
}) => isRoutedReplyDelivered(result) || result.suppressed === true;
|
||||
|
||||
/**
|
||||
* Helper to send a payload via route-reply (async).
|
||||
@@ -1708,7 +1716,7 @@ export async function dispatchReplyFromConfig(
|
||||
`dispatch-from-config: route-reply (plugin binding notice) failed: ${result.error ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
return result.ok;
|
||||
return isRoutedReplyHandled(result);
|
||||
}
|
||||
markInboundDedupeReplayUnsafe();
|
||||
return mode === "additive"
|
||||
@@ -2121,7 +2129,7 @@ export async function dispatchReplyFromConfig(
|
||||
} satisfies ReplyPayload;
|
||||
const result = await routeReplyToOriginating(payload);
|
||||
if (result) {
|
||||
queuedFinal = result.ok;
|
||||
queuedFinal = isRoutedReplyHandled(result);
|
||||
if (isRoutedReplyDelivered(result)) {
|
||||
routedFinalCount += 1;
|
||||
}
|
||||
@@ -2333,7 +2341,7 @@ export async function dispatchReplyFromConfig(
|
||||
});
|
||||
}
|
||||
return {
|
||||
queuedFinal: result.ok,
|
||||
queuedFinal: isRoutedReplyHandled(result),
|
||||
routedFinalCount: isRoutedReplyDelivered(result) ? 1 : 0,
|
||||
};
|
||||
}
|
||||
@@ -3326,7 +3334,7 @@ export async function dispatchReplyFromConfig(
|
||||
kind: "final",
|
||||
});
|
||||
if (result) {
|
||||
queuedFinal = result.ok || queuedFinal;
|
||||
queuedFinal = isRoutedReplyHandled(result) || queuedFinal;
|
||||
if (isRoutedReplyDelivered(result)) {
|
||||
routedFinalCount += 1;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
createUserTurnTranscriptRecorder,
|
||||
type PersistedUserTurnMessage,
|
||||
} from "../../sessions/user-turn-transcript.js";
|
||||
import type { ReplyPayload } from "../reply-payload.js";
|
||||
import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||
|
||||
const runEmbeddedAgentMock = vi.fn();
|
||||
@@ -3592,8 +3593,160 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
|
||||
expect(onBlockReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commits queued delivery state only after a payload survives routing", async () => {
|
||||
routeReplyMock.mockResolvedValue({ ok: true, delivered: true });
|
||||
resolveProviderFollowupFallbackRouteMock.mockImplementation(
|
||||
(params: { context?: { payload?: ReplyPayload } }) =>
|
||||
params.context?.payload?.text === "drop me"
|
||||
? { route: "drop", reason: "already delivered out of band" }
|
||||
: undefined,
|
||||
);
|
||||
let implicitReplyAvailable = true;
|
||||
const queuedDeliveryPayloadTransform = vi.fn((payload: ReplyPayload) =>
|
||||
implicitReplyAvailable
|
||||
? { ...payload, replyToId: "root", replyToIdSource: "implicit" as const }
|
||||
: payload,
|
||||
);
|
||||
const queuedDeliveryPayloadDidDeliver = vi.fn((payload: ReplyPayload) => {
|
||||
if (payload.replyToIdSource === "implicit") {
|
||||
implicitReplyAvailable = false;
|
||||
}
|
||||
});
|
||||
|
||||
await runMessagingCase({
|
||||
agentResult: { payloads: [{ text: "drop me" }, { text: "deliver me" }] },
|
||||
queued: {
|
||||
...baseQueuedRun("webchat"),
|
||||
originatingChannel: "discord",
|
||||
originatingTo: "channel:C1",
|
||||
queuedDeliveryPayloadTransform,
|
||||
queuedDeliveryReplyToMode: "first",
|
||||
queuedDeliveryPayloadDidDeliver,
|
||||
} as FollowupRun,
|
||||
});
|
||||
|
||||
expect(queuedDeliveryPayloadTransform).toHaveBeenCalledTimes(2);
|
||||
expect(routeReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(requireMockCallArg(routeReplyMock, 0).payload).toMatchObject({
|
||||
text: "deliver me",
|
||||
replyToId: "root",
|
||||
replyToIdSource: "implicit",
|
||||
});
|
||||
expect(requireMockCallArg(routeReplyMock, 0).replyToMode).toBe("first");
|
||||
expect(queuedDeliveryPayloadDidDeliver).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not commit queued delivery state for a successful no-send route", async () => {
|
||||
routeReplyMock
|
||||
.mockResolvedValueOnce({ ok: true, delivered: false })
|
||||
.mockResolvedValueOnce({ ok: true, delivered: true });
|
||||
let implicitReplyAvailable = true;
|
||||
const queuedDeliveryPayloadTransform = vi.fn((payload: ReplyPayload) =>
|
||||
implicitReplyAvailable
|
||||
? { ...payload, replyToId: "root", replyToIdSource: "implicit" as const }
|
||||
: payload,
|
||||
);
|
||||
const queuedDeliveryPayloadDidDeliver = vi.fn((payload: ReplyPayload) => {
|
||||
if (payload.replyToIdSource === "implicit") {
|
||||
implicitReplyAvailable = false;
|
||||
}
|
||||
});
|
||||
|
||||
await runMessagingCase({
|
||||
agentResult: { payloads: [{ text: "hidden" }, { text: "visible" }] },
|
||||
queued: {
|
||||
...baseQueuedRun("webchat"),
|
||||
originatingChannel: "discord",
|
||||
originatingTo: "channel:C1",
|
||||
queuedDeliveryPayloadTransform,
|
||||
queuedDeliveryPayloadDidDeliver,
|
||||
} as FollowupRun,
|
||||
});
|
||||
|
||||
expect(routeReplyMock).toHaveBeenCalledTimes(2);
|
||||
expect(requireMockCallArg(routeReplyMock, 1).payload).toMatchObject({
|
||||
text: "visible",
|
||||
replyToId: "root",
|
||||
replyToIdSource: "implicit",
|
||||
});
|
||||
expect(queuedDeliveryPayloadDidDeliver).toHaveBeenCalledTimes(1);
|
||||
expect(queuedDeliveryPayloadDidDeliver).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "visible" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("commits queued delivery state without fallback after a partial send", async () => {
|
||||
routeReplyMock.mockResolvedValue({
|
||||
ok: false,
|
||||
delivered: true,
|
||||
partialFailure: true,
|
||||
error: "second chunk failed",
|
||||
});
|
||||
const queuedDeliveryPayloadDidDeliver = vi.fn();
|
||||
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: { payloads: [{ text: "partially visible" }] },
|
||||
queued: {
|
||||
...baseQueuedRun("discord"),
|
||||
originatingChannel: "discord",
|
||||
originatingTo: "channel:C1",
|
||||
queuedDeliveryPayloadDidDeliver,
|
||||
} as FollowupRun,
|
||||
});
|
||||
|
||||
expect(queuedDeliveryPayloadDidDeliver).toHaveBeenCalledTimes(1);
|
||||
expect(onBlockReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not report cross-channel failure after an earlier partial visible delivery", async () => {
|
||||
routeReplyMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
delivered: true,
|
||||
partialFailure: true,
|
||||
error: "second chunk failed",
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: false, delivered: false, error: "provider unavailable" });
|
||||
const queuedDeliveryPayloadDidDeliver = vi.fn();
|
||||
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: { payloads: [{ text: "partially visible" }, { text: "fully failed" }] },
|
||||
queued: {
|
||||
...baseQueuedRun("webchat"),
|
||||
originatingChannel: "discord",
|
||||
originatingTo: "channel:C1",
|
||||
queuedDeliveryPayloadDidDeliver,
|
||||
} as FollowupRun,
|
||||
});
|
||||
|
||||
expect(queuedDeliveryPayloadDidDeliver).toHaveBeenCalledTimes(1);
|
||||
expect(onBlockReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not commit queued delivery state when dispatcher only accepts the payload", async () => {
|
||||
resolveProviderFollowupFallbackRouteMock.mockReturnValue({ route: "dispatcher" });
|
||||
const queuedDeliveryPayloadDidDeliver = vi.fn();
|
||||
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: { payloads: [{ text: "dispatcher queued" }] },
|
||||
queued: {
|
||||
...baseQueuedRun("webchat"),
|
||||
queuedDeliveryPayloadDidDeliver,
|
||||
} as FollowupRun,
|
||||
});
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "dispatcher queued" }),
|
||||
);
|
||||
expect(queuedDeliveryPayloadDidDeliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses exact NO_REPLY followups without origin or dispatcher delivery", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const queuedDeliveryPayloadTransform = vi.fn((payload: ReplyPayload) => ({
|
||||
...payload,
|
||||
channelData: { telegram: { standardMessage: true } },
|
||||
}));
|
||||
runEmbeddedAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: ` ${DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText} ` }],
|
||||
meta: {},
|
||||
@@ -3604,9 +3757,16 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
|
||||
defaultModel: "anthropic/claude-opus-4-6",
|
||||
});
|
||||
|
||||
await runner(createQueuedRun({ originatingChannel: undefined, originatingTo: undefined }));
|
||||
await runner(
|
||||
createQueuedRun({
|
||||
originatingChannel: undefined,
|
||||
originatingTo: undefined,
|
||||
queuedDeliveryPayloadTransform,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(routeReplyMock).not.toHaveBeenCalled();
|
||||
expect(queuedDeliveryPayloadTransform).not.toHaveBeenCalled();
|
||||
expect(typing.markRunComplete).toHaveBeenCalledTimes(1);
|
||||
expect(typing.markDispatchIdle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -368,7 +368,12 @@ export function createFollowupRunner(params: {
|
||||
}
|
||||
await opts.onBlockReply(payload);
|
||||
};
|
||||
for (const payload of sendablePayloads) {
|
||||
for (const sourcePayload of sendablePayloads) {
|
||||
// Transform at routing time so channel-owned single-use state observes the
|
||||
// previous payload's actual delivery outcome, not just its queue position.
|
||||
const payload = queued.queuedDeliveryPayloadTransform
|
||||
? queued.queuedDeliveryPayloadTransform(sourcePayload)
|
||||
: sourcePayload;
|
||||
const providerRoute = deliveryPlan.resolveFollowupRoute({
|
||||
payload,
|
||||
originatingChannel,
|
||||
@@ -411,6 +416,7 @@ export function createFollowupRunner(params: {
|
||||
requesterSenderUsername: queued.run.senderUsername,
|
||||
requesterSenderE164: queued.run.senderE164,
|
||||
threadId: queued.originatingThreadId,
|
||||
replyToMode: queued.queuedDeliveryReplyToMode,
|
||||
cfg: runtimeConfig,
|
||||
mirror: hasTranscriptOwner ? false : options.mirror,
|
||||
replyKind,
|
||||
@@ -419,22 +425,43 @@ export function createFollowupRunner(params: {
|
||||
if (!result.ok) {
|
||||
const errorMsg = result.error ?? "unknown error";
|
||||
logVerbose(`followup queue: route-reply failed: ${errorMsg}`);
|
||||
const provider = resolveOriginMessageProvider({
|
||||
provider: queued.run.messageProvider,
|
||||
});
|
||||
const origin = resolveOriginMessageProvider({
|
||||
originatingChannel,
|
||||
});
|
||||
if (opts?.onBlockReply) {
|
||||
if (origin && origin === provider) {
|
||||
await sendDispatcherPayload(payload);
|
||||
} else {
|
||||
crossChannelRouteFailureNeedsNotice = true;
|
||||
if (result.delivered) {
|
||||
const provider = resolveOriginMessageProvider({
|
||||
provider: queued.run.messageProvider,
|
||||
});
|
||||
const origin = resolveOriginMessageProvider({
|
||||
originatingChannel,
|
||||
});
|
||||
if (origin && provider && origin !== provider) {
|
||||
routedAnyCrossChannelPayloadToOrigin = true;
|
||||
}
|
||||
defaultRuntime.error?.(
|
||||
`followup queue: route-reply partially failed after visible delivery: ${errorMsg}`,
|
||||
);
|
||||
queued.queuedDeliveryPayloadDidDeliver?.(payload);
|
||||
} else {
|
||||
defaultRuntime.error?.(`followup queue: route-reply failed: ${errorMsg}`);
|
||||
const provider = resolveOriginMessageProvider({
|
||||
provider: queued.run.messageProvider,
|
||||
});
|
||||
const origin = resolveOriginMessageProvider({
|
||||
originatingChannel,
|
||||
});
|
||||
if (opts?.onBlockReply) {
|
||||
if (origin && origin === provider) {
|
||||
await sendDispatcherPayload(payload);
|
||||
} else {
|
||||
crossChannelRouteFailureNeedsNotice = true;
|
||||
}
|
||||
} else {
|
||||
defaultRuntime.error?.(`followup queue: route-reply failed: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (result.partialFailure) {
|
||||
defaultRuntime.error?.(
|
||||
`followup queue: route-reply partially failed after visible delivery: ${result.error ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
const provider = resolveOriginMessageProvider({
|
||||
provider: queued.run.messageProvider,
|
||||
});
|
||||
@@ -444,6 +471,9 @@ export function createFollowupRunner(params: {
|
||||
if (origin && provider && origin !== provider) {
|
||||
routedAnyCrossChannelPayloadToOrigin = true;
|
||||
}
|
||||
if (result.delivered) {
|
||||
queued.queuedDeliveryPayloadDidDeliver?.(payload);
|
||||
}
|
||||
}
|
||||
} else if (deliveryRoute === "dispatcher") {
|
||||
await sendDispatcherPayload(payload);
|
||||
@@ -464,7 +494,7 @@ export function createFollowupRunner(params: {
|
||||
}
|
||||
};
|
||||
|
||||
return async (queued: FollowupRun) => {
|
||||
const runQueuedFollowup = async (queued: FollowupRun) => {
|
||||
if (isFollowupRunAborted(queued)) {
|
||||
completeFollowupRunLifecycle(queued);
|
||||
typing.markRunComplete();
|
||||
@@ -1348,4 +1378,8 @@ export function createFollowupRunner(params: {
|
||||
typing.markDispatchIdle();
|
||||
}
|
||||
};
|
||||
return async (queued: FollowupRun) =>
|
||||
queued.queuedExecutionContext
|
||||
? await queued.queuedExecutionContext(() => runQueuedFollowup(queued))
|
||||
: await runQueuedFollowup(queued);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1260,6 +1260,10 @@ export async function runPreparedReply(
|
||||
...(queuedFollowupAbortSignal ? { abortSignal: queuedFollowupAbortSignal } : {}),
|
||||
deliveryCorrelations: opts?.queuedDeliveryCorrelations,
|
||||
queuedLifecycle: opts?.queuedFollowupLifecycle,
|
||||
queuedDeliveryPayloadTransform: opts?.queuedDeliveryPayloadTransform,
|
||||
queuedDeliveryReplyToMode: opts?.queuedDeliveryReplyToMode,
|
||||
queuedDeliveryPayloadDidDeliver: opts?.queuedDeliveryPayloadDidDeliver,
|
||||
queuedExecutionContext: opts?.queuedExecutionContext,
|
||||
messageId: sessionCtx.MessageSidFull ?? sessionCtx.MessageSid,
|
||||
summaryLine: baseBodyTrimmedRaw,
|
||||
enqueuedAt: Date.now(),
|
||||
|
||||
@@ -699,14 +699,33 @@ describe("followup queue collect routing", () => {
|
||||
cap: 2,
|
||||
dropPolicy: "summarize",
|
||||
};
|
||||
const queuedDeliveryPayloadTransform: NonNullable<
|
||||
FollowupRun["queuedDeliveryPayloadTransform"]
|
||||
> = vi.fn((payload) => payload);
|
||||
const queuedDeliveryPayloadDidDeliver: NonNullable<
|
||||
FollowupRun["queuedDeliveryPayloadDidDeliver"]
|
||||
> = vi.fn();
|
||||
const queuedExecutionContext: NonNullable<FollowupRun["queuedExecutionContext"]> = vi.fn(
|
||||
async (run) => await run(),
|
||||
);
|
||||
|
||||
enqueueFollowupRun(
|
||||
key,
|
||||
createRun({
|
||||
prompt: "first",
|
||||
originatingChannel: "slack",
|
||||
originatingTo: "channel:A",
|
||||
}),
|
||||
{
|
||||
...createRun({
|
||||
prompt: "first",
|
||||
originatingChannel: "slack",
|
||||
originatingTo: "channel:A",
|
||||
originatingAccountId: "account-a",
|
||||
originatingThreadId: "thread-a",
|
||||
originatingReplyToId: "reply-a",
|
||||
originatingChatType: "channel",
|
||||
}),
|
||||
queuedDeliveryPayloadTransform,
|
||||
queuedDeliveryReplyToMode: "first",
|
||||
queuedDeliveryPayloadDidDeliver,
|
||||
queuedExecutionContext,
|
||||
},
|
||||
settings,
|
||||
);
|
||||
enqueueFollowupRun(
|
||||
@@ -736,6 +755,153 @@ describe("followup queue collect routing", () => {
|
||||
expect(calls[1]?.prompt).toBe("third");
|
||||
expect(calls[2]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap.");
|
||||
expect(calls[2]?.prompt).toContain("- first");
|
||||
expect(calls[2]?.queuedDeliveryPayloadTransform).toBe(queuedDeliveryPayloadTransform);
|
||||
expect(calls[2]?.queuedDeliveryReplyToMode).toBe("first");
|
||||
expect(calls[2]?.queuedDeliveryPayloadDidDeliver).toBe(queuedDeliveryPayloadDidDeliver);
|
||||
expect(calls[2]?.queuedExecutionContext).toBe(queuedExecutionContext);
|
||||
expect(calls[2]).toMatchObject({
|
||||
originatingChannel: "slack",
|
||||
originatingTo: "channel:A",
|
||||
originatingAccountId: "account-a",
|
||||
originatingThreadId: "thread-a",
|
||||
originatingReplyToId: "reply-a",
|
||||
originatingChatType: "channel",
|
||||
});
|
||||
});
|
||||
|
||||
it("summarizes mixed-route overflow sources per route", async () => {
|
||||
const key = `test-collect-mixed-summary-routes-${Date.now()}`;
|
||||
const calls: FollowupRun[] = [];
|
||||
const done = createDeferred<void>();
|
||||
const runFollowup = async (run: FollowupRun) => {
|
||||
calls.push(run);
|
||||
if (calls.length >= 4) {
|
||||
done.resolve();
|
||||
}
|
||||
};
|
||||
const settings: QueueSettings = {
|
||||
mode: "collect",
|
||||
debounceMs: 0,
|
||||
cap: 2,
|
||||
dropPolicy: "summarize",
|
||||
};
|
||||
|
||||
enqueueFollowupRun(
|
||||
key,
|
||||
createRun({ prompt: "first", originatingChannel: "slack", originatingTo: "channel:A" }),
|
||||
settings,
|
||||
);
|
||||
enqueueFollowupRun(
|
||||
key,
|
||||
createRun({ prompt: "second", originatingChannel: "telegram", originatingTo: "chat:B" }),
|
||||
settings,
|
||||
);
|
||||
enqueueFollowupRun(
|
||||
key,
|
||||
createRun({ prompt: "third", originatingChannel: "discord", originatingTo: "channel:C" }),
|
||||
settings,
|
||||
);
|
||||
enqueueFollowupRun(
|
||||
key,
|
||||
createRun({ prompt: "fourth", originatingChannel: "discord", originatingTo: "channel:C" }),
|
||||
settings,
|
||||
);
|
||||
|
||||
scheduleFollowupDrain(key, runFollowup);
|
||||
await done.promise;
|
||||
|
||||
expect(calls.slice(0, 2).map((call) => call.prompt)).toEqual(["third", "fourth"]);
|
||||
expect(calls[2]).toMatchObject({
|
||||
originatingChannel: "slack",
|
||||
originatingTo: "channel:A",
|
||||
});
|
||||
expect(calls[2]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap.");
|
||||
expect(calls[2]?.prompt).toContain("- first");
|
||||
expect(calls[3]).toMatchObject({
|
||||
originatingChannel: "telegram",
|
||||
originatingTo: "chat:B",
|
||||
});
|
||||
expect(calls[3]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap.");
|
||||
expect(calls[3]?.prompt).toContain("- second");
|
||||
});
|
||||
|
||||
it("compacts same-route runtime overflow under one summary owner", async () => {
|
||||
const key = `test-collect-runtime-summary-owner-${Date.now()}`;
|
||||
const calls: FollowupRun[] = [];
|
||||
const done = createDeferred<void>();
|
||||
const firstComplete = vi.fn();
|
||||
const secondComplete = vi.fn();
|
||||
const firstTransform: NonNullable<FollowupRun["queuedDeliveryPayloadTransform"]> = vi.fn(
|
||||
(payload) => payload,
|
||||
);
|
||||
const secondTransform: NonNullable<FollowupRun["queuedDeliveryPayloadTransform"]> = vi.fn(
|
||||
(payload) => payload,
|
||||
);
|
||||
const runFollowup = async (run: FollowupRun) => {
|
||||
calls.push(run);
|
||||
if (calls.length >= 3) {
|
||||
done.resolve();
|
||||
}
|
||||
};
|
||||
const settings: QueueSettings = {
|
||||
mode: "collect",
|
||||
debounceMs: 0,
|
||||
cap: 2,
|
||||
dropPolicy: "summarize",
|
||||
};
|
||||
|
||||
enqueueFollowupRun(
|
||||
key,
|
||||
{
|
||||
...createRun({
|
||||
prompt: "first",
|
||||
originatingChannel: "telegram",
|
||||
originatingTo: "chat:A",
|
||||
originatingReplyToId: "1",
|
||||
}),
|
||||
queuedDeliveryPayloadTransform: firstTransform,
|
||||
queuedLifecycle: { onComplete: firstComplete },
|
||||
},
|
||||
settings,
|
||||
);
|
||||
enqueueFollowupRun(
|
||||
key,
|
||||
{
|
||||
...createRun({
|
||||
prompt: "second",
|
||||
originatingChannel: "telegram",
|
||||
originatingTo: "chat:A",
|
||||
originatingReplyToId: "2",
|
||||
}),
|
||||
queuedDeliveryPayloadTransform: secondTransform,
|
||||
queuedLifecycle: { onComplete: secondComplete },
|
||||
},
|
||||
settings,
|
||||
);
|
||||
enqueueFollowupRun(
|
||||
key,
|
||||
createRun({ prompt: "third", originatingChannel: "telegram", originatingTo: "chat:A" }),
|
||||
settings,
|
||||
);
|
||||
enqueueFollowupRun(
|
||||
key,
|
||||
createRun({ prompt: "fourth", originatingChannel: "telegram", originatingTo: "chat:A" }),
|
||||
settings,
|
||||
);
|
||||
|
||||
scheduleFollowupDrain(key, runFollowup);
|
||||
await done.promise;
|
||||
|
||||
expect(calls.slice(0, 2).map((call) => call.prompt)).toEqual(["third", "fourth"]);
|
||||
expect(calls[2]?.prompt).toContain("[Queue overflow] Dropped 2 messages due to cap.");
|
||||
expect(calls[2]?.prompt).toContain("- first");
|
||||
expect(calls[2]?.prompt).toContain("- second");
|
||||
expect(calls[2]?.originatingReplyToId).toBe("2");
|
||||
expect(calls[2]?.queuedDeliveryPayloadTransform).toBe(secondTransform);
|
||||
await vi.waitFor(() => {
|
||||
expect(firstComplete).toHaveBeenCalledTimes(1);
|
||||
expect(secondComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves collect order when authorization changes more than once", async () => {
|
||||
@@ -1431,26 +1597,20 @@ describe("followup queue collect routing", () => {
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("completes summarized room-event lifecycle when overflow summary delivery fails", async () => {
|
||||
it("does not retry a grouped overflow summary after terminal delivery failure", async () => {
|
||||
const key = `test-overflow-summary-lifecycle-failure-${Date.now()}`;
|
||||
const calls: FollowupRun[] = [];
|
||||
const firstAttempt = createDeferred<void>();
|
||||
const releaseRetry = createDeferred<void>();
|
||||
const done = createDeferred<void>();
|
||||
const summaryFailed = createDeferred<void>();
|
||||
const onComplete = vi.fn();
|
||||
let attempts = 0;
|
||||
const runFollowup = async (run: FollowupRun) => {
|
||||
calls.push(run);
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
firstAttempt.resolve();
|
||||
throw new Error("transient failure");
|
||||
if (run.prompt.includes("[Queue overflow]")) {
|
||||
summaryFailed.resolve();
|
||||
throw new Error("terminal failure");
|
||||
}
|
||||
await releaseRetry.promise;
|
||||
done.resolve();
|
||||
};
|
||||
const settings: QueueSettings = {
|
||||
mode: "followup",
|
||||
mode: "collect",
|
||||
debounceMs: 0,
|
||||
cap: 1,
|
||||
dropPolicy: "summarize",
|
||||
@@ -1469,21 +1629,17 @@ describe("followup queue collect routing", () => {
|
||||
enqueueFollowupRun(key, createRun({ prompt: "live followup" }), settings);
|
||||
|
||||
scheduleFollowupDrain(key, runFollowup);
|
||||
await firstAttempt.promise;
|
||||
await summaryFailed.promise;
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
expect(getExistingFollowupQueue(key)?.summarySources).toHaveLength(0);
|
||||
|
||||
releaseRetry.resolve();
|
||||
await done.promise;
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]?.prompt).toBe("live followup");
|
||||
expect(calls[1]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap.");
|
||||
expect(calls[1]?.prompt).toContain("- dropped ambient");
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
expect(getExistingFollowupQueue(key)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ export function createQueueTestRun(params: {
|
||||
originatingTo?: string;
|
||||
originatingAccountId?: string;
|
||||
originatingThreadId?: string | number;
|
||||
originatingReplyToId?: string;
|
||||
originatingChatType?: string;
|
||||
currentInboundEventKind?: FollowupRun["currentInboundEventKind"];
|
||||
}): FollowupRun {
|
||||
return {
|
||||
@@ -33,6 +35,8 @@ export function createQueueTestRun(params: {
|
||||
originatingTo: params.originatingTo,
|
||||
originatingAccountId: params.originatingAccountId,
|
||||
originatingThreadId: params.originatingThreadId,
|
||||
originatingReplyToId: params.originatingReplyToId,
|
||||
originatingChatType: params.originatingChatType,
|
||||
currentInboundEventKind: params.currentInboundEventKind,
|
||||
run: {
|
||||
agentId: "agent",
|
||||
|
||||
@@ -4,6 +4,8 @@ import { defaultRuntime } from "../../../runtime.js";
|
||||
import { resolveGlobalMap } from "../../../shared/global-singleton.js";
|
||||
import {
|
||||
buildCollectPrompt,
|
||||
buildQueueSummaryLine,
|
||||
buildQueueSummaryPrompt,
|
||||
beginQueueDrain,
|
||||
clearQueueSummaryState,
|
||||
drainCollectQueueStep,
|
||||
@@ -52,39 +54,29 @@ export function kickFollowupDrainIfIdle(key: string): void {
|
||||
|
||||
type OriginRoutingMetadata = Pick<
|
||||
FollowupRun,
|
||||
"originatingChannel" | "originatingTo" | "originatingAccountId" | "originatingThreadId"
|
||||
| "originatingChannel"
|
||||
| "originatingTo"
|
||||
| "originatingAccountId"
|
||||
| "originatingThreadId"
|
||||
| "originatingReplyToId"
|
||||
| "originatingChatType"
|
||||
>;
|
||||
|
||||
function resolveOriginRoutingMetadata(items: FollowupRun[]): OriginRoutingMetadata {
|
||||
const metadata: OriginRoutingMetadata = {};
|
||||
for (const item of items) {
|
||||
if (!metadata.originatingChannel && item.originatingChannel) {
|
||||
metadata.originatingChannel = item.originatingChannel;
|
||||
}
|
||||
if (!metadata.originatingTo && item.originatingTo) {
|
||||
metadata.originatingTo = item.originatingTo;
|
||||
}
|
||||
if (!metadata.originatingAccountId && item.originatingAccountId) {
|
||||
metadata.originatingAccountId = item.originatingAccountId;
|
||||
}
|
||||
// Support both number (Telegram topic) and string (Slack thread_ts) thread IDs.
|
||||
if (
|
||||
metadata.originatingThreadId == null &&
|
||||
item.originatingThreadId != null &&
|
||||
item.originatingThreadId !== ""
|
||||
) {
|
||||
metadata.originatingThreadId = item.originatingThreadId;
|
||||
}
|
||||
if (
|
||||
metadata.originatingChannel &&
|
||||
metadata.originatingTo &&
|
||||
metadata.originatingAccountId &&
|
||||
metadata.originatingThreadId != null
|
||||
) {
|
||||
break;
|
||||
}
|
||||
const owner = items.find((item) => item.originatingChannel && item.originatingTo) ?? items.at(-1);
|
||||
if (!owner) {
|
||||
return {};
|
||||
}
|
||||
return metadata;
|
||||
return {
|
||||
...(owner.originatingChannel ? { originatingChannel: owner.originatingChannel } : {}),
|
||||
...(owner.originatingTo ? { originatingTo: owner.originatingTo } : {}),
|
||||
...(owner.originatingAccountId ? { originatingAccountId: owner.originatingAccountId } : {}),
|
||||
...(owner.originatingThreadId != null && owner.originatingThreadId !== ""
|
||||
? { originatingThreadId: owner.originatingThreadId }
|
||||
: {}),
|
||||
...(owner.originatingReplyToId ? { originatingReplyToId: owner.originatingReplyToId } : {}),
|
||||
...(owner.originatingChatType ? { originatingChatType: owner.originatingChatType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Keep this key aligned with the fields that affect per-message authorization or
|
||||
@@ -137,6 +129,15 @@ function splitCollectItemsByAuthorization(items: FollowupRun[]): FollowupRun[][]
|
||||
return groups;
|
||||
}
|
||||
|
||||
function hasMixedSummarySourceContexts(items: FollowupRun[]): boolean {
|
||||
return (
|
||||
items.length > 1 &&
|
||||
(hasCrossChannelItems(items, resolveCrossChannelKey) ||
|
||||
splitCollectItemsByAuthorization(items).length > 1 ||
|
||||
items.some(hasRuntimeOnlyFollowupMetadata))
|
||||
);
|
||||
}
|
||||
|
||||
function renderCollectItem(item: FollowupRun, idx: number): string {
|
||||
const senderLabel =
|
||||
item.run.senderName ?? item.run.senderUsername ?? item.run.senderId ?? item.run.senderE164;
|
||||
@@ -169,6 +170,10 @@ type FollowupRuntimeMetadata = Pick<
|
||||
| "abortSignal"
|
||||
| "deliveryCorrelations"
|
||||
| "queuedLifecycle"
|
||||
| "queuedDeliveryPayloadTransform"
|
||||
| "queuedDeliveryReplyToMode"
|
||||
| "queuedDeliveryPayloadDidDeliver"
|
||||
| "queuedExecutionContext"
|
||||
>;
|
||||
|
||||
function hasCurrentTurnRuntimeMetadata(item: FollowupRun): boolean {
|
||||
@@ -184,7 +189,11 @@ function hasRuntimeOnlyFollowupMetadata(item: FollowupRun): boolean {
|
||||
hasCurrentTurnRuntimeMetadata(item) ||
|
||||
item.abortSignal ||
|
||||
item.deliveryCorrelations?.length ||
|
||||
item.queuedLifecycle,
|
||||
item.queuedLifecycle ||
|
||||
item.queuedDeliveryPayloadTransform ||
|
||||
item.queuedDeliveryReplyToMode ||
|
||||
item.queuedDeliveryPayloadDidDeliver ||
|
||||
item.queuedExecutionContext,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,11 +246,98 @@ function collectRuntimeMetadata(
|
||||
queuedLifecycle:
|
||||
singletonOwner?.queuedLifecycle ??
|
||||
(items.length === 1 ? lifecycleSource?.queuedLifecycle : undefined),
|
||||
queuedDeliveryPayloadTransform:
|
||||
singletonOwner?.queuedDeliveryPayloadTransform ??
|
||||
(items.length === 1 ? items[0]?.queuedDeliveryPayloadTransform : undefined),
|
||||
queuedDeliveryReplyToMode:
|
||||
singletonOwner?.queuedDeliveryReplyToMode ??
|
||||
(items.length === 1 ? items[0]?.queuedDeliveryReplyToMode : undefined),
|
||||
queuedDeliveryPayloadDidDeliver:
|
||||
singletonOwner?.queuedDeliveryPayloadDidDeliver ??
|
||||
(items.length === 1 ? items[0]?.queuedDeliveryPayloadDidDeliver : undefined),
|
||||
queuedExecutionContext:
|
||||
singletonOwner?.queuedExecutionContext ??
|
||||
(items.length === 1 ? items[0]?.queuedExecutionContext : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function collectSummaryRuntimeMetadata(items: FollowupRun[]): FollowupRuntimeMetadata {
|
||||
return collectRuntimeMetadata(items, items.length === 1 ? items[0] : undefined);
|
||||
function collectSummaryRuntimeMetadata(
|
||||
items: FollowupRun[],
|
||||
): FollowupRuntimeMetadata & OriginRoutingMetadata {
|
||||
const runtimeOwner = items.findLast(
|
||||
(item) =>
|
||||
item.queuedDeliveryPayloadTransform ||
|
||||
item.queuedDeliveryReplyToMode ||
|
||||
item.queuedDeliveryPayloadDidDeliver ||
|
||||
item.queuedExecutionContext,
|
||||
);
|
||||
const summaryOwner = runtimeOwner ?? items.at(-1);
|
||||
return {
|
||||
...resolveOriginRoutingMetadata(summaryOwner ? [summaryOwner] : []),
|
||||
...collectRuntimeMetadata(items, runtimeOwner ?? (items.length === 1 ? items[0] : undefined)),
|
||||
};
|
||||
}
|
||||
|
||||
type FollowupSummaryGroup = {
|
||||
sources: FollowupRun[];
|
||||
summaryLines: string[];
|
||||
droppedCount: number;
|
||||
};
|
||||
|
||||
function resolveFollowupSummaryGroupKey(item: FollowupRun): string {
|
||||
return JSON.stringify([
|
||||
item.originatingChannel ?? "",
|
||||
item.originatingTo ?? "",
|
||||
item.originatingAccountId ?? "",
|
||||
item.originatingThreadId ?? "",
|
||||
resolveFollowupAuthorizationKey(item.run),
|
||||
]);
|
||||
}
|
||||
|
||||
function buildFollowupSummaryGroups(queue: {
|
||||
droppedCount: number;
|
||||
summaryLines: string[];
|
||||
summarySources?: FollowupRun[];
|
||||
}): FollowupSummaryGroup[] {
|
||||
const groups = new Map<string, FollowupSummaryGroup>();
|
||||
const sources = queue.summarySources ?? [];
|
||||
for (const [index, source] of sources.entries()) {
|
||||
const key = resolveFollowupSummaryGroupKey(source);
|
||||
const group = groups.get(key) ?? { sources: [], summaryLines: [], droppedCount: 0 };
|
||||
group.sources.push(source);
|
||||
group.summaryLines.push(
|
||||
queue.summaryLines[index] ??
|
||||
buildQueueSummaryLine(source.summaryLine?.trim() || source.prompt.trim()),
|
||||
);
|
||||
group.droppedCount += 1;
|
||||
groups.set(key, group);
|
||||
}
|
||||
const retainedCount = sources.length;
|
||||
const unretainedCount = Math.max(0, queue.droppedCount - retainedCount);
|
||||
const firstGroup = groups.values().next().value;
|
||||
if (firstGroup) {
|
||||
firstGroup.droppedCount += unretainedCount;
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
function consumeFollowupSummaryGroup(
|
||||
queue: {
|
||||
droppedCount: number;
|
||||
summaryLines: string[];
|
||||
summarySources?: FollowupRun[];
|
||||
},
|
||||
group: FollowupSummaryGroup,
|
||||
): void {
|
||||
for (const source of group.sources) {
|
||||
const index = queue.summarySources?.indexOf(source) ?? -1;
|
||||
if (index >= 0) {
|
||||
queue.summarySources?.splice(index, 1);
|
||||
queue.summaryLines.splice(index, 1);
|
||||
}
|
||||
completeFollowupRunLifecycle(source);
|
||||
}
|
||||
queue.droppedCount = Math.max(0, queue.droppedCount - group.droppedCount);
|
||||
}
|
||||
|
||||
function clearFollowupQueueSummaryState(queue: {
|
||||
@@ -398,9 +494,17 @@ export function scheduleFollowupDrain(
|
||||
// Debug: `pnpm test src/auto-reply/reply/reply-flow.test.ts`
|
||||
// Check if messages span multiple channels.
|
||||
// If so, process individually to preserve per-message routing.
|
||||
// Retained overflow sources still own their original route and auth
|
||||
// context. Drain live items first when combining them would deliver a
|
||||
// summary through a different owner.
|
||||
const summarySources = queue.summarySources ?? [];
|
||||
const isCrossChannel =
|
||||
hasCrossChannelItems(queue.items, resolveCrossChannelKey) ||
|
||||
queue.items.some(hasRuntimeOnlyFollowupMetadata);
|
||||
queue.items.some(hasRuntimeOnlyFollowupMetadata) ||
|
||||
hasMixedSummarySourceContexts(summarySources) ||
|
||||
summarySources.some(hasRuntimeOnlyFollowupMetadata) ||
|
||||
(summarySources.length > 0 &&
|
||||
hasCrossChannelItems([...queue.items, ...summarySources], resolveCrossChannelKey));
|
||||
if (collectState.forceIndividualCollect && !isCrossChannel && queue.items.length > 1) {
|
||||
collectState.forceIndividualCollect = false;
|
||||
}
|
||||
@@ -419,13 +523,49 @@ export function scheduleFollowupDrain(
|
||||
const summaryOnlyPrompt = summaryOnly.prompt;
|
||||
const run = queue.lastRun;
|
||||
if (summaryOnlyPrompt && run) {
|
||||
const summaryGroups = buildFollowupSummaryGroups(queue);
|
||||
if (summaryGroups.length > 0) {
|
||||
for (const group of summaryGroups) {
|
||||
const owner = group.sources.at(-1);
|
||||
if (!owner) {
|
||||
continue;
|
||||
}
|
||||
const prompt = buildQueueSummaryPrompt({
|
||||
state: {
|
||||
dropPolicy: "summarize",
|
||||
droppedCount: group.droppedCount,
|
||||
summaryLines: [...group.summaryLines],
|
||||
},
|
||||
noun: "message",
|
||||
});
|
||||
if (!prompt) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await effectiveRunFollowup({
|
||||
prompt,
|
||||
run: owner.run,
|
||||
enqueuedAt: Date.now(),
|
||||
...collectSummaryRuntimeMetadata(group.sources),
|
||||
...collectQueuedImages(group.sources),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isFollowupRunDeferredError(err)) {
|
||||
consumeFollowupSummaryGroup(queue, group);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
consumeFollowupSummaryGroup(queue, group);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await runWithDeferredSummaryRestore(summaryOnly.restore, async () => {
|
||||
await runWithSummarySourceCleanup(queue, async () => {
|
||||
await effectiveRunFollowup({
|
||||
prompt: summaryOnlyPrompt,
|
||||
run,
|
||||
enqueuedAt: Date.now(),
|
||||
...collectSummaryRuntimeMetadata([]),
|
||||
...collectSummaryRuntimeMetadata(queue.summarySources ?? []),
|
||||
...collectQueuedImages(queue.items),
|
||||
});
|
||||
});
|
||||
@@ -459,7 +599,7 @@ export function scheduleFollowupDrain(
|
||||
prompt: summary,
|
||||
run,
|
||||
enqueuedAt: Date.now(),
|
||||
...collectSummaryRuntimeMetadata([]),
|
||||
...collectSummaryRuntimeMetadata(queue.summarySources ?? []),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { SilentReplyPromptMode } from "../../../agents/system-prompt.types.
|
||||
import type { ChatType } from "../../../channels/chat-type.js";
|
||||
import type { InboundEventKind } from "../../../channels/inbound-event/kind.js";
|
||||
import type { SessionEntry } from "../../../config/sessions.js";
|
||||
import type { ReplyToMode } from "../../../config/types.js";
|
||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js";
|
||||
import type { InputProvenance } from "../../../sessions/input-provenance.js";
|
||||
@@ -16,6 +17,7 @@ import type {
|
||||
QueuedReplyLifecycle,
|
||||
SourceReplyDeliveryMode,
|
||||
} from "../../get-reply-options.types.js";
|
||||
import type { ReplyPayload } from "../../reply-payload.js";
|
||||
import type { OriginatingChannelType } from "../../templating.js";
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js";
|
||||
|
||||
@@ -58,6 +60,10 @@ export type FollowupRun = {
|
||||
abortSignal?: AbortSignal;
|
||||
deliveryCorrelations?: QueuedReplyDeliveryCorrelation[];
|
||||
queuedLifecycle?: QueuedReplyLifecycle;
|
||||
queuedDeliveryPayloadTransform?: (payload: ReplyPayload) => ReplyPayload;
|
||||
queuedDeliveryReplyToMode?: ReplyToMode;
|
||||
queuedDeliveryPayloadDidDeliver?: (payload: ReplyPayload) => void;
|
||||
queuedExecutionContext?: <T>(run: () => Promise<T>) => Promise<T>;
|
||||
/** Provider message ID, when available (for deduplication). */
|
||||
messageId?: string;
|
||||
summaryLine?: string;
|
||||
|
||||
@@ -164,6 +164,7 @@ async function expectSlackNoDelivery(
|
||||
...overrides,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.delivered).toBe(false);
|
||||
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
return res;
|
||||
}
|
||||
@@ -357,6 +358,7 @@ describe("routeReply", () => {
|
||||
|
||||
expect(res).toEqual({
|
||||
ok: true,
|
||||
delivered: false,
|
||||
suppressed: true,
|
||||
reason: "cancelled_by_reply_payload_sending_hook",
|
||||
});
|
||||
@@ -371,6 +373,40 @@ describe("routeReply", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports visible delivery when a later batch part fails", async () => {
|
||||
mocks.deliverOutboundPayloads.mockImplementationOnce(
|
||||
async ({
|
||||
onPayloadDeliveryOutcome,
|
||||
}: {
|
||||
onPayloadDeliveryOutcome?: (outcome: unknown) => void;
|
||||
}) => {
|
||||
onPayloadDeliveryOutcome?.({
|
||||
index: 0,
|
||||
status: "failed",
|
||||
error: new Error("second chunk failed"),
|
||||
sentBeforeError: true,
|
||||
stage: "platform_send",
|
||||
});
|
||||
return [{ channel: "telegram", messageId: "visible-1" }];
|
||||
},
|
||||
);
|
||||
|
||||
const res = await routeReply({
|
||||
payload: { text: "hello" },
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
cfg: {} as never,
|
||||
});
|
||||
|
||||
expect(res).toMatchObject({
|
||||
ok: false,
|
||||
delivered: true,
|
||||
partialFailure: true,
|
||||
messageId: "visible-1",
|
||||
});
|
||||
expect(res.error).toContain("second chunk failed");
|
||||
});
|
||||
|
||||
it("suppresses routed delivery when reply payload hooks cancel", async () => {
|
||||
mocks.deliverOutboundPayloads.mockImplementationOnce(
|
||||
async ({
|
||||
@@ -396,6 +432,7 @@ describe("routeReply", () => {
|
||||
|
||||
expect(res).toEqual({
|
||||
ok: true,
|
||||
delivered: false,
|
||||
suppressed: true,
|
||||
reason: "cancelled_by_reply_payload_sending_hook",
|
||||
});
|
||||
@@ -427,6 +464,7 @@ describe("routeReply", () => {
|
||||
|
||||
expect(res).toEqual({
|
||||
ok: true,
|
||||
delivered: false,
|
||||
suppressed: true,
|
||||
reason: "empty_after_reply_payload_sending_hook",
|
||||
});
|
||||
@@ -604,17 +642,19 @@ describe("routeReply", () => {
|
||||
expect(lastDeliveryPayload().text).toBe("BTW\nQuestion: what is 17 * 19?\n\n323");
|
||||
});
|
||||
|
||||
it("passes replyToId to Telegram sends", async () => {
|
||||
it("passes reply targeting policy to Telegram sends", async () => {
|
||||
await routeReply({
|
||||
payload: { text: "hi", replyToId: "123" },
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
replyToMode: "first",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expectLastDeliveryFields({
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
replyToId: "123",
|
||||
replyToMode: "first",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
|
||||
import { getBundledChannelPlugin } from "../../channels/plugins/bundled.js";
|
||||
import { getLoadedChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import { normalizeChatChannelId } from "../../channels/registry.js";
|
||||
import type { ReplyToMode } from "../../config/types.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
|
||||
@@ -62,6 +63,8 @@ export type RouteReplyParams = {
|
||||
requesterSenderE164?: string;
|
||||
/** Thread id for replies (Telegram topic id or Matrix thread event id). */
|
||||
threadId?: string | number;
|
||||
/** Source reply fan-out policy for transports that split one payload. */
|
||||
replyToMode?: ReplyToMode;
|
||||
/** Config for provider-specific settings. */
|
||||
cfg: OpenClawConfig;
|
||||
/** Optional abort signal for cooperative cancellation. */
|
||||
@@ -81,8 +84,12 @@ export type RouteReplyParams = {
|
||||
export type RouteReplyResult = {
|
||||
/** Whether the reply was sent successfully. */
|
||||
ok: boolean;
|
||||
/** Whether provider-visible delivery actually occurred. */
|
||||
delivered: boolean;
|
||||
/** True when a hook intentionally suppressed provider delivery. */
|
||||
suppressed?: boolean;
|
||||
/** True when part of the payload was visible before a later send failed. */
|
||||
partialFailure?: boolean;
|
||||
/** Suppression reason when delivery was intentionally skipped. */
|
||||
reason?: "cancelled_by_reply_payload_sending_hook" | "empty_after_reply_payload_sending_hook";
|
||||
/** Optional message ID from the provider. */
|
||||
@@ -102,7 +109,7 @@ export type RouteReplyResult = {
|
||||
export async function routeReply(params: RouteReplyParams): Promise<RouteReplyResult> {
|
||||
const { payload, channel, to, accountId, threadId, cfg, abortSignal } = params;
|
||||
if (shouldSuppressReasoningPayload(payload)) {
|
||||
return { ok: true };
|
||||
return { ok: true, delivered: false };
|
||||
}
|
||||
const normalizedChannel = normalizeMessageChannel(channel);
|
||||
const channelId =
|
||||
@@ -140,7 +147,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
: undefined,
|
||||
});
|
||||
if (!normalized) {
|
||||
return { ok: true };
|
||||
return { ok: true, delivered: false };
|
||||
}
|
||||
const externalPayload: ReplyPayload = {
|
||||
...normalized,
|
||||
@@ -175,21 +182,22 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
},
|
||||
)
|
||||
) {
|
||||
return { ok: true };
|
||||
return { ok: true, delivered: false };
|
||||
}
|
||||
|
||||
if (channel === INTERNAL_MESSAGE_CHANNEL) {
|
||||
return {
|
||||
ok: false,
|
||||
delivered: false,
|
||||
error: "Webchat routing not supported for queued replies",
|
||||
};
|
||||
}
|
||||
|
||||
if (!channelId) {
|
||||
return { ok: false, error: `Unknown channel: ${String(channel)}` };
|
||||
return { ok: false, delivered: false, error: `Unknown channel: ${String(channel)}` };
|
||||
}
|
||||
if (abortSignal?.aborted) {
|
||||
return { ok: false, error: "Reply routing aborted" };
|
||||
return { ok: false, delivered: false, error: "Reply routing aborted" };
|
||||
}
|
||||
|
||||
const replyTransport =
|
||||
@@ -243,6 +251,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
},
|
||||
},
|
||||
replyToId: resolvedReplyToId ?? null,
|
||||
replyToMode: params.replyToMode,
|
||||
threadId: resolvedThreadId,
|
||||
session: outboundSession,
|
||||
signal: abortSignal,
|
||||
@@ -258,9 +267,19 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
if (send.status === "failed" || send.status === "partial_failed") {
|
||||
if (send.status === "failed") {
|
||||
throw send.error;
|
||||
}
|
||||
if (send.status === "partial_failed") {
|
||||
const last = send.results.at(-1);
|
||||
return {
|
||||
ok: false,
|
||||
delivered: true,
|
||||
partialFailure: true,
|
||||
messageId: last?.messageId,
|
||||
error: `Partially routed reply to ${channel}: ${formatErrorMessage(send.error)}`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
send.status === "suppressed" &&
|
||||
(send.reason === "cancelled_by_reply_payload_sending_hook" ||
|
||||
@@ -268,6 +287,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
) {
|
||||
return {
|
||||
ok: true,
|
||||
delivered: false,
|
||||
suppressed: true,
|
||||
reason: send.reason,
|
||||
};
|
||||
@@ -275,11 +295,12 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
const results = send.status === "sent" ? send.results : [];
|
||||
|
||||
const last = results.at(-1);
|
||||
return { ok: true, messageId: last?.messageId };
|
||||
return { ok: true, delivered: results.length > 0, messageId: last?.messageId };
|
||||
} catch (err) {
|
||||
const message = formatErrorMessage(err);
|
||||
return {
|
||||
ok: false,
|
||||
delivered: false,
|
||||
error: `Failed to route reply to ${channel}: ${message}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -230,7 +230,7 @@ export async function withDurableMessageSendContext<T>(
|
||||
});
|
||||
const failedOutcome = payloadOutcomes.find((outcome) => outcome.status === "failed");
|
||||
if (failedOutcome) {
|
||||
if (results.length > 0) {
|
||||
if (results.length > 0 || failedOutcome.sentBeforeError) {
|
||||
return {
|
||||
status: "partial_failed",
|
||||
results,
|
||||
@@ -269,7 +269,7 @@ export async function withDurableMessageSendContext<T>(
|
||||
};
|
||||
} 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),
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1826,6 +1826,43 @@ describe("deliverOutboundPayloads", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves adapter partial-delivery metadata without a message receipt", async () => {
|
||||
const adapterError = Object.assign(new Error("later adapter chunk failed"), {
|
||||
sentBeforeError: true,
|
||||
visibleReplySent: true,
|
||||
});
|
||||
const sendPayload = vi.fn().mockRejectedValue(adapterError);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "matrix",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "matrix",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText: vi.fn(),
|
||||
sendMedia: vi.fn(),
|
||||
sendPayload,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
deliverOutboundPayloads({
|
||||
cfg: {},
|
||||
channel: "matrix",
|
||||
to: "!room",
|
||||
payloads: [{ text: "long peer reply", channelData: { matrix: { mode: "notice" } } }],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
sentBeforeError: true,
|
||||
payloadOutcomes: [expect.objectContaining({ status: "failed", sentBeforeError: true })],
|
||||
});
|
||||
});
|
||||
|
||||
it("strips internal runtime scaffolding copied into rendered and normalized nested payloads", async () => {
|
||||
const sendPayload = vi.fn().mockResolvedValue({
|
||||
channel: "matrix" as const,
|
||||
|
||||
@@ -1208,6 +1208,11 @@ function toOutboundDeliveryError(params: {
|
||||
cause: params.error,
|
||||
results: params.results,
|
||||
payloadOutcomes: params.payloadOutcomes,
|
||||
sentBeforeError:
|
||||
typeof params.error === "object" &&
|
||||
params.error !== null &&
|
||||
"sentBeforeError" in params.error &&
|
||||
params.error.sentBeforeError === true,
|
||||
stage: params.stage,
|
||||
});
|
||||
}
|
||||
@@ -1935,7 +1940,12 @@ async function deliverOutboundPayloadsCore(
|
||||
index: payloadIndex,
|
||||
status: "failed",
|
||||
error: err,
|
||||
sentBeforeError: results.length > 0,
|
||||
sentBeforeError:
|
||||
results.length > 0 ||
|
||||
(typeof err === "object" &&
|
||||
err !== null &&
|
||||
"sentBeforeError" in err &&
|
||||
err.sentBeforeError === true),
|
||||
stage: "platform_send",
|
||||
});
|
||||
errorDeliveryDiagnostics(err);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user