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:
Peter Steinberger
2026-07-13 13:00:03 -07:00
committed by GitHub
parent c18e71be7e
commit b68060a7cb
64 changed files with 7861 additions and 1004 deletions
+11
View File
@@ -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.
+4 -3
View File
@@ -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
+5
View File
@@ -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>
+430 -3
View File
@@ -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;
+87 -12
View File
@@ -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,
+4
View File
@@ -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 () => {
+22 -8
View File
@@ -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
+1 -1
View File
@@ -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 = {
+355 -58
View File
@@ -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
+1
View File
@@ -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. */
+249 -72
View File
@@ -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,
});
+434 -3
View File
@@ -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;
};
}
+19
View File
@@ -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" });
+40 -3
View File
@@ -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()));
},
};
}
+27
View File
@@ -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;
}
+24
View File
@@ -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();
}
+328
View File
@@ -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
View File
@@ -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 };
+207
View File
@@ -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;
+1
View File
@@ -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. */
+2
View File
@@ -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,
+12 -1
View File
@@ -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 -6
View File
@@ -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" });
+2 -2
View File
@@ -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;
+14 -6
View File
@@ -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;
}
+161 -1
View File
@@ -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);
});
+48 -14
View File
@@ -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",
+4
View File
@@ -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(),
+180 -24
View File
@@ -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",
+175 -35
View File
@@ -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
View File
@@ -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;
+41 -1
View File
@@ -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",
});
});
+28 -7
View File
@@ -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}`,
};
}
+59
View File
@@ -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");
+2 -2
View File
@@ -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),
+2 -1
View File
@@ -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";
}
}
+37
View File
@@ -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,
+11 -1
View File
@@ -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);
+16 -1
View File
@@ -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,
});
});
});
+3 -1
View File
@@ -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) {