fix(telegram): restore bot-to-bot LTS release probe (#106755)

* docs: clarify Codex worktree invocations

* fix(telegram): deliver peer-bot replies visibly

* fix(telegram): harden deferred cleanup

* fix(ci): align LTS Telegram checks

(cherry picked from commit b68060a7cb)
This commit is contained in:
Peter Steinberger
2026-07-13 13:00:03 -07:00
committed by Dallin Romney
parent 0dfcb1f202
commit c9c4072f4c
58 changed files with 6419 additions and 1024 deletions
+11
View File
@@ -120,6 +120,13 @@ Skills own workflows; root owns hard policy and routing.
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <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`).
@@ -132,6 +139,8 @@ Skills own workflows; root owns hard policy and routing.
- Visual proof: use Crabbox, set up like a user, then screenshot-verify. No harness/bypass/shortcut unless explicitly asked.
- Small/narrow tests, lints, format checks, and type probes are fine locally only in a healthy normal checkout.
- In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
- Codex-worktree commit after equivalent remote hook proof: `git commit --no-verify --no-gpg-sign`; do not invoke `scripts/committer`.
- Git continuation commands run commit hooks too; pre-format and use hook-free continuation only after equivalent remote proof.
- Full suites, broad changed gates, Docker/package/E2E/live/cross-OS proof, or anything that bogs down the Mac: Crabbox/Testbox.
- One/few files local. If a local command fans out, stop and move broad proof to Crabbox/Testbox.
- Before handoff/push: prove touched surface. Before landing to `main`: issue proof plus appropriate full/broad proof unless scope is clearly narrow.
@@ -234,6 +243,8 @@ Skills own workflows; root owns hard policy and routing.
## Git
- zsh: quote optional glob patterns; unmatched globs abort commands.
- LTS worktrees: Testbox full sync can mix main hydration with release packages; use direct Crabbox when lock/package shapes differ.
- Commit via `scripts/committer "<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
@@ -316,10 +316,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
@@ -51,6 +51,7 @@ import {
import { resolveTelegramTransport } from "./fetch.js";
import { resolveTelegramScopedGroupConfig } from "./group-config-helpers.js";
import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js";
import { createTelegramPeerBotAdmissionCoordinator } from "./peer-bot-admission.js";
import { stringifyTelegramRawUpdateForLog } from "./raw-update-log.js";
import { TELEGRAM_RICH_TEXT_LIMIT } from "./rich-message.js";
import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js";
@@ -389,6 +390,7 @@ export function createTelegramBotCore(
opts,
telegramDeps,
});
const peerBotAdmission = createTelegramPeerBotAdmissionCoordinator();
registerTelegramNativeCommands({
bot,
@@ -410,6 +412,7 @@ export function createTelegramBotCore(
shouldSkipUpdate,
opts,
telegramDeps,
peerBotAdmission,
});
registerTelegramHandlers({
@@ -431,6 +434,7 @@ export function createTelegramBotCore(
processMessage,
logger,
telegramDeps,
peerBotAdmission,
});
const originalStop = bot.stop.bind(bot);
File diff suppressed because it is too large Load Diff
@@ -224,8 +224,10 @@ describe("buildTelegramMessageContext requireMention precedence", () => {
});
it("lets explicit topic requireMention=true override always activation", async () => {
const afterAdmissionShouldDrop = vi.fn(async () => false);
const ctx = await buildTelegramMessageContextForTest({
message: buildForumMessage(),
options: { afterAdmissionShouldDrop },
resolveGroupActivation: () => false,
resolveGroupRequireMention: () => false,
resolveTelegramGroupConfig: () => ({
@@ -235,6 +237,44 @@ describe("buildTelegramMessageContext requireMention precedence", () => {
});
expect(ctx).toBeNull();
expect(afterAdmissionShouldDrop).toHaveBeenCalledOnce();
expect(afterAdmissionShouldDrop).toHaveBeenCalledWith(false);
});
it("lets deferred admission suppress an addressed group turn", async () => {
const afterAdmissionShouldDrop = vi.fn(async () => true);
const ctx = await buildTelegramMessageContextForTest({
message: { ...buildForumMessage(), text: "@bot hello" },
options: { afterAdmissionShouldDrop },
resolveGroupActivation: () => false,
resolveGroupRequireMention: () => true,
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: true },
topicConfig: undefined,
}),
});
expect(ctx).toBeNull();
expect(afterAdmissionShouldDrop).toHaveBeenCalledOnce();
expect(afterAdmissionShouldDrop).toHaveBeenCalledWith(true);
});
it("finalizes deferred admission when group policy drops before body admission", async () => {
const afterAdmissionShouldDrop = vi.fn(async () => false);
const ctx = await buildTelegramMessageContextForTest({
message: buildForumMessage(),
options: { afterAdmissionShouldDrop },
resolveGroupActivation: () => false,
resolveGroupRequireMention: () => false,
resolveTelegramGroupConfig: () => ({
groupConfig: { enabled: false },
topicConfig: undefined,
}),
});
expect(ctx).toBeNull();
expect(afterAdmissionShouldDrop).toHaveBeenCalledOnce();
expect(afterAdmissionShouldDrop).toHaveBeenCalledWith(false);
});
it("keeps activation fallback when no topic requireMention is configured", async () => {
+22 -8
View File
@@ -152,6 +152,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) : "";
@@ -275,7 +287,7 @@ export const buildTelegramMessageContext = async ({
reason: "non-default account requires explicit binding",
target: route.accountId,
});
return null;
return await dropBeforeAdmission();
}
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
const dmAllow = await resolveTelegramDmAllow({
@@ -310,27 +322,27 @@ export const buildTelegramMessageContext = async ({
if (!baseAccess.allowed) {
if (baseAccess.reason === "group-disabled") {
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
return null;
return await dropBeforeAdmission();
}
if (baseAccess.reason === "topic-disabled") {
logVerbose(
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
);
return null;
return await dropBeforeAdmission();
}
logVerbose(
isGroup
? `Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`
: `Blocked telegram DM sender ${senderId || "unknown"} (DM allowFrom override)`,
);
return null;
return await dropBeforeAdmission();
}
const requireTopic = directConfig?.requireTopic;
const topicRequiredButMissing = !isGroup && requireTopic === true && dmThreadId == null;
if (topicRequiredButMissing) {
logVerbose(`Blocked telegram DM ${chatId}: requireTopic=true but no topic present`);
return null;
return await dropBeforeAdmission();
}
const sendTyping = async () => {
@@ -374,7 +386,7 @@ export const buildTelegramMessageContext = async ({
upsertPairingRequest,
}))
) {
return null;
return await dropBeforeAdmission();
}
let initialTypingCueSent = false;
const ensureConfiguredBindingReady = async (): Promise<boolean> => {
@@ -484,7 +496,7 @@ export const buildTelegramMessageContext = async ({
logger,
});
if (!bodyResult) {
return null;
return await dropBeforeAdmission();
}
const groupHistoryContextMode = isGroup
@@ -495,9 +507,11 @@ export const buildTelegramMessageContext = async ({
: undefined;
if (!(await ensureConfiguredBindingReady())) {
return await dropBeforeAdmission();
}
if (await finalizeAdmission(true)) {
return null;
}
// Direct chats are now reply-eligible; send the first typing cue before
// expensive context/session construction without showing typing for dropped turns.
if (!isGroup) {
@@ -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;
};
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 () => {
({
@@ -433,6 +469,335 @@ describe("registerTelegramNativeCommands", () => {
expect(parseTelegramNativeCommandCallbackData("tgcmd:fast status")).toBeNull();
});
it("commits the peer reply target after partially visible native delivery", async () => {
const { bot, commandHandlers } = createCommandBot();
const cfg: OpenClawConfig = {
commands: { native: true },
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
};
const baseParams = createNativeCommandTestParams(cfg, {
bot,
allowFrom: ["*"],
groupAllowFrom: ["*"],
replyToMode: "first",
opts: { token: "token", replyToMode: "first" },
});
const dispatchReplyWithBufferedBlockDispatcher: TelegramNativeCommandDeps["dispatchReplyWithBufferedBlockDispatcher"] =
async (params) => {
const deliver = params.dispatcherOptions.deliver;
const info = { kind: "block" } as Parameters<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
@@ -7,6 +7,7 @@ import {
resolveDefaultModelForAgent,
resolveThinkingDefaultWithRuntimeCatalog,
} from "openclaw/plugin-sdk/agent-runtime";
import { recordChannelBotPairLoopAndCheckSuppression } from "openclaw/plugin-sdk/channel-inbound";
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native";
import {
@@ -33,6 +34,7 @@ import type {
} from "openclaw/plugin-sdk/config-contracts";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
@@ -95,6 +97,7 @@ import {
resolveTelegramConversationBaseSessionKey,
resolveTelegramConversationRoute,
} from "./conversation-route.js";
import { isTelegramDeliveryErrorVisible } from "./delivery-error.js";
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
import type { TelegramTransport } from "./fetch.js";
import {
@@ -105,6 +108,12 @@ import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
import { resolveTelegramCommandIngressAuthorization } from "./ingress.js";
import { buildInlineKeyboard } from "./inline-keyboard.js";
import { buildTelegramNativeCommandCallbackData } from "./native-command-callback-data.js";
import {
buildTelegramPeerBotAdmissionKey,
createTelegramPeerBotAdmissionCoordinator,
type TelegramPeerBotAdmissionCoordinator,
} from "./peer-bot-admission.js";
import { runWithTelegramPeerBotTurn } from "./peer-bot-turn.js";
import { recordSentMessage } from "./sent-message-cache.js";
import { getTopicName, resolveTopicNameCacheScope } from "./topic-name-cache.js";
export {
@@ -124,6 +133,37 @@ type TelegramNativeReplyChannelData = {
pin?: boolean;
};
type FastModeState = ReturnType<typeof resolveFastModeState>;
function isTelegramPeerBotMessage(msg: TelegramNativeCommandContext["message"]): boolean {
return msg?.from?.is_bot === true && msg.sender_chat == null;
}
function shouldSuppressTelegramBotCommandLoop(params: {
msg: TelegramNativeCommandContext["message"];
botId?: number;
accountId: string;
cfg: OpenClawConfig;
}): boolean {
const msg = params.msg;
const sender = msg?.from;
if (
!msg ||
!isTelegramPeerBotMessage(msg) ||
!sender ||
params.botId == null ||
sender.id === params.botId
) {
return false;
}
return recordChannelBotPairLoopAndCheckSuppression({
scopeId: params.accountId,
conversationId: `${msg.chat.id}:${msg.message_thread_id ?? ""}`,
senderId: String(sender.id),
receiverId: String(params.botId),
defaultsConfig: params.cfg.channels?.defaults?.botLoopProtection,
defaultEnabled: true,
}).suppressed;
}
type TelegramResolvedGroupConfig = {
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
@@ -134,6 +174,7 @@ type TelegramCommandAuthResult = {
isGroup: boolean;
isForum: boolean;
resolvedThreadId?: number;
admissionThreadId?: number;
senderId: string;
senderUsername: string;
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
@@ -572,6 +613,7 @@ export type RegisterTelegramHandlerParams = {
lifecycle?: import("./bot-message.js").TelegramMessageProcessorLifecycle,
) => Promise<TelegramMessageProcessingResult>;
logger: ReturnType<typeof getChildLogger>;
peerBotAdmission?: TelegramPeerBotAdmissionCoordinator;
};
export function resolveTelegramNativeCommandDisableBlockStreaming(
@@ -603,7 +645,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: {
@@ -612,6 +655,7 @@ async function resolveTelegramCommandAuth(params: {
cfg: OpenClawConfig;
accountId: string;
telegramCfg: TelegramAccountConfig;
replyToMode: ReplyToMode;
readChannelAllowFromStore: TelegramBotDeps["readChannelAllowFromStore"];
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
@@ -622,6 +666,7 @@ async function resolveTelegramCommandAuth(params: {
messageThreadId?: number,
) => TelegramResolvedGroupConfig;
requireAuth: boolean;
shouldSuppressRejection?: () => boolean;
}): Promise<TelegramCommandAuthResult | null> {
const {
msg,
@@ -629,6 +674,7 @@ async function resolveTelegramCommandAuth(params: {
cfg,
accountId,
telegramCfg,
replyToMode,
readChannelAllowFromStore,
allowFrom,
groupAllowFrom,
@@ -636,6 +682,7 @@ async function resolveTelegramCommandAuth(params: {
resolveGroupPolicy,
resolveTelegramGroupConfig,
requireAuth,
shouldSuppressRejection,
} = params;
const { chatId, isGroup, isForum, messageThreadId, threadParams } =
await resolveTelegramNativeCommandThreadContext({ msg, bot });
@@ -682,6 +729,7 @@ async function resolveTelegramCommandAuth(params: {
effectiveGroupAllow,
hasGroupAllowOverride,
} = groupAllowContext;
const admissionThreadId = resolvedThreadId ?? dmThreadId;
const effectiveDmPolicy = resolveTelegramEffectiveDmPolicy({
isGroup,
groupConfig,
@@ -716,9 +764,23 @@ async function resolveTelegramCommandAuth(params: {
});
const sendAuthMessage = async (text: string) => {
if (shouldSuppressRejection?.()) {
return null;
}
await withTelegramApiErrorLogging({
operation: "sendMessage",
fn: () => bot.api.sendMessage(chatId, text, threadParams ?? {}),
fn: () =>
bot.api.sendMessage(chatId, text, {
...(isTelegramPeerBotMessage(msg) && replyToMode !== "off"
? {
reply_parameters: {
message_id: msg.message_id,
allow_sending_without_reply: true,
},
}
: {}),
...threadParams,
}),
});
return null;
};
@@ -817,6 +879,7 @@ async function resolveTelegramCommandAuth(params: {
isGroup,
isForum,
resolvedThreadId,
...(admissionThreadId != null ? { admissionThreadId } : {}),
senderId,
senderUsername,
groupConfig,
@@ -846,7 +909,65 @@ export const registerTelegramNativeCommands = ({
shouldSkipUpdate,
telegramDeps = defaultTelegramNativeCommandDeps,
opts,
peerBotAdmission = createTelegramPeerBotAdmissionCoordinator(),
}: RegisterTelegramNativeCommandsParams) => {
// Peer-bot replies default to explicit threading for Telegram visibility.
// Operators can still disable the exception with replyToMode: "off".
const peerBotReplyToMode = opts.replyToMode ?? telegramCfg.replyToMode ?? "all";
const shouldSuppressPeerBotCommandLoop = (params: {
msg: NonNullable<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 })
@@ -1062,7 +1183,17 @@ export const registerTelegramNativeCommands = ({
bot.api.sendMessage(
chatId,
"Configured ACP binding is unavailable right now. Please try again.",
buildTelegramThreadParams(threadSpec) ?? {},
{
...buildTelegramThreadParams(threadSpec),
...(isTelegramPeerBotMessage(msg) && peerBotReplyToMode !== "off"
? {
reply_parameters: {
message_id: msg.message_id,
allow_sending_without_reply: true,
},
}
: {}),
},
),
});
return null;
@@ -1099,6 +1230,8 @@ export const registerTelegramNativeCommands = ({
chunkMode: TelegramChunkMode;
linkPreview?: boolean;
richMessages?: boolean;
standardMessages?: boolean;
defaultReplyToId?: string;
}) => ({
cfg: params.cfg,
chatId: String(params.chatId),
@@ -1112,13 +1245,15 @@ export const registerTelegramNativeCommands = ({
bot,
mediaLocalRoots: params.mediaLocalRoots,
mediaMaxBytes,
replyToMode,
replyToMode: params.standardMessages ? peerBotReplyToMode : replyToMode,
textLimit,
thread: params.threadSpec,
tableMode: params.tableMode,
chunkMode: params.chunkMode,
linkPreview: params.linkPreview,
richMessages: params.richMessages,
standardMessages: params.standardMessages,
defaultReplyToId: params.defaultReplyToId,
});
const resolveCommandTargetSessionKey = (params: {
runtimeCfg: OpenClawConfig;
@@ -1159,17 +1294,22 @@ export const registerTelegramNativeCommands = ({
if (!msg) {
return;
}
if (msg.from?.id != null && msg.from.id === ctx.me?.id) {
return;
}
if (shouldSkipUpdate(ctx)) {
return;
}
const runtimeCfg = loadFreshRuntimeConfig();
const runtimeTelegramCfg = resolveFreshTelegramConfig(runtimeCfg);
const botId = ctx.me?.id ?? bot.botInfo?.id;
const auth = await resolveTelegramCommandAuth({
msg,
bot,
cfg: runtimeCfg,
accountId,
telegramCfg: runtimeTelegramCfg,
replyToMode: peerBotReplyToMode,
readChannelAllowFromStore: telegramDeps.readChannelAllowFromStore,
allowFrom,
groupAllowFrom,
@@ -1177,10 +1317,23 @@ export const registerTelegramNativeCommands = ({
resolveGroupPolicy,
resolveTelegramGroupConfig,
requireAuth: true,
shouldSuppressRejection: () =>
shouldSuppressPeerBotCommandLoop({ msg, botId, runtimeCfg }),
});
if (!auth) {
return;
}
if (
await admitAuthorizedPeerBotCommand({
msg,
botId,
isAbortControl: normalizedCommandName === "stop",
threadId: auth.admissionThreadId,
runtimeCfg,
})
) {
return;
}
const {
chatId,
isGroup,
@@ -1342,6 +1495,14 @@ export const registerTelegramNativeCommands = ({
fn: () =>
bot.api.sendMessage(chatId, title, {
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
...(isTelegramPeerBotMessage(msg) && peerBotReplyToMode !== "off"
? {
reply_parameters: {
message_id: msg.message_id,
allow_sending_without_reply: true,
},
}
: {}),
...threadParams,
}),
});
@@ -1360,6 +1521,7 @@ export const registerTelegramNativeCommands = ({
userId: String(senderId || chatId),
targetSessionKey: sessionKey,
});
const peerBotCommand = isTelegramPeerBotMessage(msg);
const deliveryBaseOptions = buildCommandDeliveryBaseOptions({
cfg: executionCfg,
chatId,
@@ -1374,6 +1536,8 @@ export const registerTelegramNativeCommands = ({
chunkMode,
linkPreview: runtimeTelegramCfg.linkPreview,
richMessages: runtimeTelegramCfg.richMessages,
standardMessages: peerBotCommand,
defaultReplyToId: undefined,
});
let topicName: string | undefined;
if (isForum && resolvedThreadId != null) {
@@ -1439,10 +1603,12 @@ export const registerTelegramNativeCommands = ({
runtime.error?.(danger(`telegram slash: failed updating session meta: ${String(err)}`)),
});
const disableBlockStreaming =
resolveTelegramNativeCommandDisableBlockStreaming(runtimeTelegramCfg);
const disableBlockStreaming = isTelegramPeerBotMessage(msg)
? true
: resolveTelegramNativeCommandDisableBlockStreaming(runtimeTelegramCfg);
const deliveryState = {
delivered: false,
failedNonSilent: 0,
skippedNonSilent: 0,
};
@@ -1454,60 +1620,142 @@ export const registerTelegramNativeCommands = ({
channel: "telegram",
accountId: route.accountId,
});
const peerBotTurn =
peerBotCommand && msg.from?.id != null
? {
accountId: route.accountId,
chatAliases: [msg.chat.username]
.filter((value): value is string => Boolean(value))
.map((value) => `@${value}`),
chatId: String(chatId),
messageId: msg.message_id,
senderAliases: [msg.from?.username]
.filter((value): value is string => Boolean(value))
.map((value) => `@${value}`),
senderId: String(msg.from.id),
...(threadSpec.id != null ? { threadId: threadSpec.id } : {}),
}
: undefined;
const effectiveNativeReplyToMode = peerBotCommand ? peerBotReplyToMode : replyToMode;
let peerImplicitReplyAvailable = true;
const applyPeerImplicitReply = (payload: TelegramNativeReplyPayload) => {
if (
effectiveNativeReplyToMode === "off" ||
payload.replyToId != null ||
(isSingleUseReplyToMode(effectiveNativeReplyToMode) && !peerImplicitReplyAvailable)
) {
return payload;
}
return {
...payload,
replyToId: String(msg.message_id),
replyToIdSource: "implicit" as const,
};
};
const commitPeerImplicitReply = (payload: TelegramNativeReplyPayload) => {
if (
payload.replyToIdSource === "implicit" &&
isSingleUseReplyToMode(effectiveNativeReplyToMode)
) {
peerImplicitReplyAvailable = false;
}
};
const transformQueuedPeerBotPayload = (payload: TelegramNativeReplyPayload) => {
const addressedPayload = applyPeerImplicitReply(payload);
return {
...addressedPayload,
channelData: {
...addressedPayload.channelData,
telegram: {
...(addressedPayload.channelData?.telegram as Record<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);
}
}
});
}
@@ -1518,22 +1766,40 @@ export const registerTelegramNativeCommands = ({
if (!msg) {
return;
}
if (msg.from?.id != null && msg.from.id === ctx.me?.id) {
return;
}
if (shouldSkipUpdate(ctx)) {
return;
}
const chatId = msg.chat.id;
const runtimeCfg = loadFreshRuntimeConfig();
const runtimeTelegramCfg = resolveFreshTelegramConfig(runtimeCfg);
const botId = ctx.me?.id ?? bot.botInfo?.id;
const { threadParams } = await resolveTelegramNativeCommandThreadContext({ msg, bot });
const rawText = ctx.match?.trim() ?? "";
const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`;
const nativeCommandRuntime = await loadTelegramNativeCommandRuntime();
const match = nativeCommandRuntime.matchPluginCommand(commandBody);
if (!match) {
if (shouldSuppressPeerBotCommandLoop({ msg, botId, runtimeCfg })) {
return;
}
await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime,
fn: () => bot.api.sendMessage(chatId, "Command not found.", threadParams ?? {}),
fn: () =>
bot.api.sendMessage(chatId, "Command not found.", {
...(isTelegramPeerBotMessage(msg) && peerBotReplyToMode !== "off"
? {
reply_parameters: {
message_id: msg.message_id,
allow_sending_without_reply: true,
},
}
: {}),
...threadParams,
}),
});
return;
}
@@ -1543,6 +1809,7 @@ export const registerTelegramNativeCommands = ({
cfg: runtimeCfg,
accountId,
telegramCfg: runtimeTelegramCfg,
replyToMode: peerBotReplyToMode,
readChannelAllowFromStore: telegramDeps.readChannelAllowFromStore,
allowFrom,
groupAllowFrom,
@@ -1550,10 +1817,23 @@ export const registerTelegramNativeCommands = ({
resolveGroupPolicy,
resolveTelegramGroupConfig,
requireAuth: match.command.requireAuth !== false,
shouldSuppressRejection: () =>
shouldSuppressPeerBotCommandLoop({ msg, botId, runtimeCfg }),
});
if (!auth) {
return;
}
if (
await admitAuthorizedPeerBotCommand({
msg,
botId,
isAbortControl: false,
threadId: auth.admissionThreadId,
runtimeCfg,
})
) {
return;
}
const { senderId, commandAuthorized, senderIsOwner, isGroup, isForum, resolvedThreadId } =
auth;
const runtimeContext = await resolveCommandRuntimeContext({
@@ -1597,6 +1877,11 @@ export const registerTelegramNativeCommands = ({
chunkMode,
linkPreview: runtimeTelegramCfg.linkPreview,
richMessages: runtimeTelegramCfg.richMessages,
standardMessages: isTelegramPeerBotMessage(msg),
defaultReplyToId:
isTelegramPeerBotMessage(msg) && peerBotReplyToMode !== "off"
? String(msg.message_id)
: undefined,
});
const from = isGroup ? buildTelegramGroupFrom(chatId, threadSpec.id) : `telegram:${chatId}`;
const to = `telegram:${chatId}`;
@@ -1605,7 +1890,9 @@ export const registerTelegramNativeCommands = ({
let progressMessageId: number | undefined;
const progressPlaceholder = resolveTelegramProgressPlaceholder(match.command);
if (progressPlaceholder) {
// Peer bots do not receive rich edits, so bot-originated commands must
// wait for the observable standard final instead of a progress placeholder.
if (progressPlaceholder && deliveryBaseOptions.standardMessages !== true) {
try {
const sent = await withTelegramApiErrorLogging({
operation: "sendMessage",
@@ -1672,9 +1959,19 @@ export const registerTelegramNativeCommands = ({
return;
}
const deliverableResult = hasRenderableTelegramNativeReplyPayload(result)
const baseDeliverableResult = hasRenderableTelegramNativeReplyPayload(result)
? result
: { text: EMPTY_RESPONSE_FALLBACK };
const deliverableResult =
isTelegramPeerBotMessage(msg) &&
peerBotReplyToMode !== "off" &&
baseDeliverableResult.replyToId == null
? {
...baseDeliverableResult,
replyToId: String(msg.message_id),
replyToIdSource: "implicit" as const,
}
: baseDeliverableResult;
const progressResultText =
typeof deliverableResult.text === "string" && deliverableResult.text.trim().length > 0
? deliverableResult.text
@@ -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. */
+255 -83
View File
@@ -31,6 +31,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
import { resolveTelegramInlineButtons, type TelegramInlineButtons } from "../button-types.js";
import { splitTelegramCaption } from "../caption.js";
import { markTelegramDeliveryErrorVisible } from "../delivery-error.js";
import {
markdownToTelegramChunks,
markdownToTelegramHtml,
@@ -40,6 +41,10 @@ import {
import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js";
import { splitTelegramRichMessageTextChunks, TELEGRAM_RICH_TEXT_LIMIT } from "../rich-message.js";
import { buildInlineKeyboard, reactMessageTelegram } from "../send.js";
import {
buildTelegramStandardFragmentAbort,
buildTelegramStandardTextChunks,
} from "../standard-text.js";
import { resolveTelegramVoiceSend } from "../voice.js";
import {
buildTelegramSendParams,
@@ -82,7 +87,7 @@ type TelegramReplyQuoteForSend = {
type TelegramDeliveryTextChunk = {
text: string;
plainText: string;
textMode: "html";
textMode: "html" | "markdown";
};
type ChunkTextFn = (markdown: string) => TelegramDeliveryTextChunk[];
@@ -93,7 +98,20 @@ function buildChunkTextResolver(params: {
tableMode?: MarkdownTableMode;
richMessages?: boolean;
skipEntityDetection?: boolean;
standardMessages?: boolean;
}): ChunkTextFn {
if (params.standardMessages) {
return (markdown: string) =>
buildTelegramStandardTextChunks(markdown, { tableMode: params.tableMode }).map((chunk) =>
Object.assign(
{
text: chunk.htmlText ?? chunk.plainText,
plainText: chunk.plainText,
},
{ textMode: chunk.htmlText ? ("html" as const) : ("markdown" as const) },
),
);
}
if (params.richMessages === true) {
return (markdown: string) =>
splitTelegramRichMessageTextChunks({
@@ -145,6 +163,59 @@ function filterEmptyTelegramTextChunks<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;
@@ -207,41 +278,67 @@ 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 } }
: {}),
richMessages: params.richMessages,
linkPreview: params.linkPreview,
tableMode: params.tableMode,
silent: params.silent,
replyMarkup,
},
);
if (firstDeliveredMessageId == null) {
firstDeliveredMessageId = messageId;
}
},
});
} catch (error) {
if (isFramedStandardTextBatch(chunks)) {
if (madeFramedBatchProgress(params.progress, progressBeforeBatch)) {
await retireIncompleteFramedBatch({
bot: params.bot,
chatId: params.chatId,
runtime: params.runtime,
thread: params.thread,
textMode: chunk.textMode,
plainText: chunk.plainText,
richMessages: params.richMessages,
linkPreview: params.linkPreview,
tableMode: params.tableMode,
replyToMessageId: params.replyToId,
chunks,
silent: params.silent,
replyMarkup,
},
);
if (firstDeliveredMessageId == null) {
firstDeliveredMessageId = messageId;
});
}
},
});
// A peer cannot consume a prefix without the end frame. Keep the logical
// turn undelivered so the caller can send a visible terminal fallback.
restoreIncompleteFramedBatchProgress(params.progress, progressBeforeBatch);
}
if (params.progress.hasDelivered) {
throw markTelegramDeliveryErrorVisible(error);
}
throw error;
}
return firstDeliveredMessageId;
}
@@ -262,27 +359,51 @@ async function sendPendingFollowUpText(params: {
progress: DeliveryProgress;
}): Promise<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: chunk.textMode,
plainText: chunk.plainText,
richMessages: params.richMessages,
linkPreview: params.linkPreview,
tableMode: params.tableMode,
silent: params.silent,
replyMarkup,
});
},
});
const progressBeforeBatch = { ...params.progress };
try {
await sendChunkedTelegramReplyText({
chunks,
progress: params.progress,
replyToId: params.replyToId,
replyToMode: resolveTelegramTextChunkReplyToMode(chunks, params.replyToMode),
replyMarkup: params.replyMarkup,
markDelivered,
sendChunk: async ({ chunk, replyToMessageId, replyMarkup }) => {
await sendTelegramText(params.bot, params.chatId, chunk.text, params.runtime, {
replyToMessageId,
thread: params.thread,
textMode: chunk.textMode ?? "markdown",
...(chunk.plainText !== undefined
? { standardMessage: { plainText: chunk.plainText } }
: {}),
richMessages: params.richMessages,
linkPreview: params.linkPreview,
tableMode: params.tableMode,
silent: params.silent,
replyMarkup,
});
},
});
} catch (error) {
if (isFramedStandardTextBatch(chunks)) {
if (madeFramedBatchProgress(params.progress, progressBeforeBatch)) {
await retireIncompleteFramedBatch({
bot: params.bot,
chatId: params.chatId,
runtime: params.runtime,
thread: params.thread,
replyToMessageId: params.replyToId,
chunks,
silent: params.silent,
});
}
restoreIncompleteFramedBatchProgress(params.progress, progressBeforeBatch);
}
if (params.progress.hasDelivered) {
throw markTelegramDeliveryErrorVisible(error);
}
throw error;
}
}
function isVoiceMessagesForbidden(err: unknown): boolean {
@@ -316,6 +437,8 @@ async function sendTelegramVoiceFallbackText(opts: {
text: string;
chunkText: ChunkTextFn;
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
replyQuoteMessageId?: number;
replyQuotePosition?: number;
replyQuoteEntities?: unknown[];
@@ -329,32 +452,61 @@ async function sendTelegramVoiceFallbackText(opts: {
}): Promise<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: chunk.textMode,
plainText: chunk.plainText,
richMessages: opts.richMessages,
linkPreview: opts.linkPreview,
tableMode: opts.tableMode,
silent: opts.silent,
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
const progressBeforeBatch = { ...opts.progress };
try {
await sendChunkedTelegramReplyText({
chunks,
progress: opts.progress,
replyToId: opts.replyToId,
replyToMode: resolveTelegramTextChunkReplyToMode(chunks, opts.replyToMode),
replyMarkup: opts.replyMarkup,
replyQuoteText: opts.replyQuoteText,
quoteOnlyOnFirstChunk: true,
// Track visible chunks immediately; the caller increments the logical reply
// count once after the complete fallback succeeds.
markDelivered: (progress) => {
progress.hasDelivered = true;
},
sendChunk: async ({ chunk, isFirstChunk, replyToMessageId, replyMarkup, replyQuoteText }) => {
const messageId = await sendTelegramText(opts.bot, opts.chatId, chunk.text, opts.runtime, {
replyToMessageId,
replyQuoteMessageId: isFirstChunk ? opts.replyQuoteMessageId : undefined,
replyQuoteText,
replyQuotePosition: isFirstChunk ? opts.replyQuotePosition : undefined,
replyQuoteEntities: isFirstChunk ? opts.replyQuoteEntities : undefined,
thread: opts.thread,
textMode: chunk.textMode ?? "markdown",
...(chunk.plainText !== undefined
? { standardMessage: { plainText: chunk.plainText } }
: {}),
richMessages: opts.richMessages,
linkPreview: opts.linkPreview,
tableMode: opts.tableMode,
silent: opts.silent,
replyMarkup,
});
firstDeliveredMessageId ??= messageId;
},
});
if (firstDeliveredMessageId == null) {
firstDeliveredMessageId = messageId;
} catch (error) {
if (isFramedStandardTextBatch(chunks)) {
if (madeFramedBatchProgress(opts.progress, progressBeforeBatch)) {
await retireIncompleteFramedBatch({
bot: opts.bot,
chatId: opts.chatId,
runtime: opts.runtime,
thread: opts.thread,
replyToMessageId: opts.replyToId,
chunks,
silent: opts.silent,
});
}
restoreIncompleteFramedBatchProgress(opts.progress, progressBeforeBatch);
}
if (replyToForChunk) {
appliedReplyTo = true;
if (opts.progress.hasDelivered) {
throw markTelegramDeliveryErrorVisible(error);
}
throw error;
}
return firstDeliveredMessageId;
}
@@ -525,6 +677,8 @@ async function deliverMediaReply(params: {
text: fallbackText,
chunkText: params.chunkText,
replyToId: voiceFallbackReplyTo,
replyToMode: params.replyToMode,
progress: params.progress,
replyQuoteMessageId: params.replyQuoteMessageId,
replyQuotePosition: params.replyQuotePosition,
replyQuoteEntities: params.replyQuoteEntities,
@@ -552,6 +706,7 @@ async function deliverMediaReply(params: {
delete noCaptionParams.caption;
delete noCaptionParams.parse_mode;
await sendVoiceMedia(noCaptionParams);
markReplyApplied(params.progress, replyToMessageId);
const fallbackText = resolveVoiceFallbackText(params.reply);
if (fallbackText?.trim()) {
await sendTelegramVoiceFallbackText({
@@ -560,7 +715,9 @@ async function deliverMediaReply(params: {
runtime: params.runtime,
text: fallbackText,
chunkText: params.chunkText,
replyToId: undefined,
replyToId: params.replyToId,
replyToMode: params.replyToMode,
progress: params.progress,
thread: params.thread,
richMessages: params.richMessages,
tableMode: params.tableMode,
@@ -749,6 +906,10 @@ export async function deliverReplies(params: {
chunkMode?: ChunkMode;
/** Opt into Telegram Bot API 10.1 rich text delivery. */
richMessages?: boolean;
/** Standard Bot API messages remain visible to bot-originated QA/automation turns. */
standardMessages?: boolean;
/** Reply target synthesized for bot-originated terminal/native responses. */
defaultReplyToId?: string;
/** Callback invoked before sending a voice message to switch typing indicator. */
onVoiceRecording?: () => Promise<void> | void;
/** Controls whether link previews are shown. Default: true (previews enabled). */
@@ -789,6 +950,7 @@ export async function deliverReplies(params: {
tableMode: params.tableMode,
richMessages: params.richMessages,
skipEntityDetection: params.linkPreview === false,
standardMessages: params.standardMessages,
});
const candidateReplies: ReplyPayload[] = [];
for (const reply of params.replies) {
@@ -829,8 +991,6 @@ export async function deliverReplies(params: {
const telegramData = reply.channelData?.telegram as TelegramReplyChannelData | undefined;
const reactionEmoji =
typeof telegramData?.reaction?.emoji === "string" ? telegramData.reaction.emoji : undefined;
const replyToId =
params.replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId);
if (reactionEmoji && typeof replyToId !== "number") {
params.runtime.error?.(danger("Telegram reaction requires a reply target"));
continue;
@@ -850,6 +1010,18 @@ export async function deliverReplies(params: {
? reply.spokenText
: undefined;
const hookContent = spokenHookContent ?? rawContent;
// Parsed reply directives predate provenance stamps; their explicit tag
// fields remain authoritative for direct native-command delivery.
const hasExplicitReplyTarget =
reply.replyToId != null &&
(reply.replyToIdSource !== "implicit" ||
reply.replyToTag === true ||
reply.replyToCurrent === true);
const replyToId =
hasExplicitReplyTarget || params.replyToMode !== "off"
? resolveTelegramReplyId(reply.replyToId ?? params.defaultReplyToId)
: undefined;
const effectiveReplyToMode: ReplyToMode = hasExplicitReplyTarget ? "all" : params.replyToMode;
const replyQuote = resolveReplyQuoteForSend({
replyToId,
replyQuoteByMessageId: params.replyQuoteByMessageId,
@@ -934,7 +1106,7 @@ export async function deliverReplies(params: {
linkPreview: params.linkPreview,
silent: params.silent,
replyToId,
replyToMode: params.replyToMode,
replyToMode: effectiveReplyToMode,
progress,
});
} else if (mediaList.length > 0) {
@@ -960,7 +1132,7 @@ export async function deliverReplies(params: {
replyQuoteEntities: replyQuote.entities,
replyMarkup,
replyToId,
replyToMode: params.replyToMode,
replyToMode: effectiveReplyToMode,
progress,
});
firstDeliveredMessageId = mediaDelivery.firstDeliveredMessageId;
@@ -1005,7 +1177,7 @@ export async function deliverReplies(params: {
isGroup: params.mirrorIsGroup,
groupId: params.mirrorGroupId,
});
throw error;
throw progress.hasDelivered ? markTelegramDeliveryErrorVisible(error) : error;
}
}
+3 -2
View File
@@ -105,6 +105,7 @@ export async function sendTelegramText(
textMode?: "markdown" | "html";
plainText?: string;
richMessages?: boolean;
standardMessage?: { plainText: string };
linkPreview?: boolean;
tableMode?: MarkdownTableMode;
silent?: boolean;
@@ -121,7 +122,7 @@ export async function sendTelegramText(
silent: opts?.silent,
});
const textMode = opts?.textMode ?? "markdown";
if (opts?.richMessages === true) {
if (opts?.richMessages === true && !opts.standardMessage) {
const richMessage = buildTelegramRichMessage(text, textMode, {
skipEntityDetection: opts.linkPreview === false,
tableMode: opts.tableMode,
@@ -147,7 +148,7 @@ export async function sendTelegramText(
const linkPreviewEnabled = opts?.linkPreview ?? true;
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
const fallbackText = opts?.plainText ?? text;
const fallbackText = opts?.standardMessage?.plainText ?? opts?.plainText ?? text;
const hasFallbackText = fallbackText.trim().length > 0;
const sendPlainFallback = async () => {
const res = await sendTelegramWithThreadFallback({
@@ -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))
);
}
@@ -138,6 +138,55 @@ describe("telegramOutbound", () => {
expect(result).toEqual({ channel: "telegram", messageId: "tg-2", chatId: "12345" });
});
it("marks later payload media failures as partial delivery", async () => {
const error = new Error("second media send failed");
sendMessageTelegramMock
.mockResolvedValueOnce({ messageId: "tg-1", chatId: "12345" })
.mockRejectedValueOnce(error);
await expect(
telegramOutbound.sendPayload!({
cfg: {} as never,
to: "12345",
text: "",
payload: {
text: "Album",
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
},
deps: { sendTelegram: sendMessageTelegramMock },
}),
).rejects.toMatchObject({ sentBeforeError: true, visibleReplySent: true });
expect(sendMessageTelegramMock).toHaveBeenCalledTimes(2);
});
it("consumes implicit single-use replies across standard payload media", async () => {
sendMessageTelegramMock
.mockResolvedValueOnce({ messageId: "tg-1", chatId: "12345" })
.mockResolvedValueOnce({ messageId: "tg-2", chatId: "12345" });
await telegramOutbound.sendPayload!({
cfg: {} as never,
to: "12345",
text: "",
payload: {
text: "Peer reply",
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
channelData: { telegram: { standardMessage: true } },
},
replyToId: "900",
replyToIdSource: "implicit",
replyToMode: "first",
deps: { sendTelegram: sendMessageTelegramMock },
});
expect(callOptionsAt(sendMessageTelegramMock, 0, "12345", "Peer reply")).toMatchObject({
replyToMessageId: 900,
replyToMode: "first",
});
expect(callOptionsAt(sendMessageTelegramMock, 1, "12345", "").replyToMessageId).toBeUndefined();
});
it("uses interactive button labels as fallback text for button-only payloads", async () => {
sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-buttons", chatId: "12345" });
+41 -2
View File
@@ -18,9 +18,11 @@ import {
resolvePayloadMediaUrls,
sendPayloadMediaSequenceOrFallback,
} from "openclaw/plugin-sdk/reply-payload";
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { TelegramInlineButtons } from "./button-types.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import { markTelegramDeliveryErrorVisible } from "./delivery-error.js";
import { splitTelegramHtmlChunks } from "./format.js";
import { resolveTelegramInteractiveTextFallback } from "./interactive-fallback.js";
import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js";
@@ -58,6 +60,8 @@ async function resolveTelegramSendContext(params: {
deps?: OutboundSendDeps;
accountId?: string | null;
replyToId?: string | null;
replyToMode?: TelegramSendOpts["replyToMode"];
replyToIdSource?: TelegramSendOpts["replyToIdSource"];
threadId?: string | number | null;
formatting?: OutboundDeliveryFormattingOptions;
silent?: boolean;
@@ -72,6 +76,8 @@ async function resolveTelegramSendContext(params: {
tableMode?: OutboundDeliveryFormattingOptions["tableMode"];
messageThreadId?: number;
replyToMessageId?: number;
replyToMode?: TelegramSendOpts["replyToMode"];
replyToIdSource?: TelegramSendOpts["replyToIdSource"];
accountId?: string;
silent?: boolean;
gatewayClientScopes?: readonly string[];
@@ -85,6 +91,8 @@ async function resolveTelegramSendContext(params: {
cfg: params.cfg,
messageThreadId: parseTelegramThreadId(params.threadId),
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
...(params.replyToMode ? { replyToMode: params.replyToMode } : {}),
...(params.replyToIdSource ? { replyToIdSource: params.replyToIdSource } : {}),
accountId: params.accountId ?? undefined,
silent: params.silent,
gatewayClientScopes: params.gatewayClientScopes,
@@ -124,6 +132,7 @@ export async function sendTelegramPayloadMessages(params: {
buttons?: TelegramInlineButtons;
quoteText?: string;
reaction?: { emoji?: unknown; replyToId?: unknown; replyToCurrent?: unknown };
standardMessage?: boolean;
}
| undefined;
const quoteText =
@@ -147,6 +156,7 @@ export async function sendTelegramPayloadMessages(params: {
const payloadOpts = {
...params.baseOpts,
quoteText,
standardMessage: telegramData?.standardMessage === true,
...(params.payload.audioAsVoice === true ? { asVoice: true } : {}),
};
if (reactionEmoji) {
@@ -167,18 +177,47 @@ export async function sendTelegramPayloadMessages(params: {
return { messageId: String(replyToMessageId), chatId: params.to };
}
const singleUseImplicitReply =
payloadOpts.standardMessage &&
payloadOpts.replyToMessageId != null &&
payloadOpts.replyToIdSource !== "explicit" &&
payloadOpts.replyToMode != null &&
isSingleUseReplyToMode(payloadOpts.replyToMode);
let implicitReplyAvailable = true;
let deliveredSendCount = 0;
const sendWithReplyFanout = async (textLocal: string, options: TelegramSendOpts) => {
const effectiveOptions =
singleUseImplicitReply && !implicitReplyAvailable
? { ...options, replyToMessageId: undefined }
: options;
let result: Awaited<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({
text,
mediaUrls,
fallbackResult: { messageId: "unknown", chatId: params.to },
sendNoMedia: async () =>
await params.send(params.to, text, {
await sendWithReplyFanout(text, {
...payloadOpts,
buttons,
}),
send: async ({ text: textLocal, mediaUrl, isFirst }) =>
await params.send(params.to, textLocal, {
await sendWithReplyFanout(textLocal, {
...payloadOpts,
mediaUrl,
...(isFirst ? { buttons } : {}),
@@ -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();
}
+102 -22
View File
@@ -3,7 +3,7 @@ import * as grammy from "grammy";
import { type ApiClientOptions, Bot, HttpError } from "grammy";
import type { ReactionType, ReactionTypeEmoji } from "grammy/types";
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
import type { MarkdownTableMode, ReplyToMode } from "openclaw/plugin-sdk/config-contracts";
import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime";
import { formatUncaughtError } from "openclaw/plugin-sdk/error-runtime";
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
@@ -21,6 +21,7 @@ import { buildTypingThreadParams } from "./bot/helpers.js";
import type { TelegramInlineButtons } from "./button-types.js";
import { splitTelegramCaption } from "./caption.js";
import { asTelegramClientFetch, createTelegramClientFetch } from "./client-fetch.js";
import { markTelegramDeliveryErrorVisible } from "./delivery-error.js";
import { resolveTelegramTransport } from "./fetch.js";
import {
renderTelegramHtmlText,
@@ -65,6 +66,11 @@ import {
resolveMarkdownTableMode,
} from "./send.runtime.js";
import { recordSentMessage } from "./sent-message-cache.js";
import {
buildTelegramStandardFragmentAbort,
buildTelegramStandardTextChunks,
stripTelegramStandardFragmentMarker,
} from "./standard-text.js";
import { maybePersistResolvedTelegramTarget } from "./target-writeback.js";
import {
normalizeTelegramChatId,
@@ -103,6 +109,8 @@ type TelegramSendOpts = {
retry?: RetryConfig;
textMode?: "markdown" | "html";
tableMode?: MarkdownTableMode;
/** Use the standard Bot API text method instead of rich messages. */
standardMessage?: boolean;
/** Send audio as voice message instead of audio file. Defaults to false. */
asVoice?: boolean;
/** Send video as video note instead of regular video. Defaults to false. */
@@ -111,6 +119,10 @@ type TelegramSendOpts = {
silent?: boolean;
/** Message ID to reply to (for threading) */
replyToMessageId?: number;
/** Controls whether the reply target applies once or to every chunk. */
replyToMode?: ReplyToMode;
/** Distinguishes explicit agent targets from ambient reply-mode targets. */
replyToIdSource?: "explicit" | "implicit";
/** Quote text for Telegram reply_parameters. */
quoteText?: string;
/** Forum topic thread ID (for forum supergroups) */
@@ -687,7 +699,7 @@ export async function sendMessageTelegram(
});
const textMode = opts.textMode ?? "markdown";
const useRichMessages = account.config.richMessages === true;
const useRichMessages = account.config.richMessages === true && opts.standardMessage !== true;
const tableMode =
opts.tableMode ??
resolveMarkdownTableMode({
@@ -702,6 +714,7 @@ export async function sendMessageTelegram(
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
type TelegramTextChunk = {
text: string;
plainText: string;
htmlText?: string;
};
@@ -765,37 +778,85 @@ export async function sendMessageTelegram(
const sendTelegramTextChunks = async (
chunks: TelegramTextChunk[],
context: string,
priorVisibleDelivery = false,
): Promise<{ messageId: string; chatId: string }> => {
let lastMessageId = "";
let lastChatId = chatId;
let lastAcceptedParams: TelegramThreadScopedParams | undefined;
let lastContextMessage: TelegramMessageLike | undefined;
let lastContextMessageId: number | undefined;
let sentChunkCount = 0;
const isFramedStandardBatch = opts.standardMessage === true && chunks.length > 1;
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index];
if (!chunk) {
continue;
}
const { result: res, acceptedParams } = await sendTelegramTextChunk(
chunk,
buildTextParams(index === chunks.length - 1),
);
const messageId = resolveTelegramMessageIdOrThrow(res, context);
recordSentMessage(chatId, messageId, cfg);
try {
const { result: res, acceptedParams } = await sendTelegramTextChunk(
chunk,
buildTextParams(index === chunks.length - 1),
);
sentChunkCount += 1;
const messageId = resolveTelegramMessageIdOrThrow(res, context);
recordSentMessage(chatId, messageId, cfg);
if (isFramedStandardBatch) {
lastContextMessage = res;
lastContextMessageId = messageId;
} else {
await recordOutboundMessageForPromptContext({
cfg,
account,
chatId,
message: res,
messageId,
text: chunk.plainText,
...(acceptedParams?.message_thread_id !== undefined
? { messageThreadId: acceptedParams.message_thread_id }
: {}),
});
}
lastMessageId = String(messageId);
lastChatId = String(res?.chat?.id ?? chatId);
lastAcceptedParams = acceptedParams;
} catch (error) {
if (sentChunkCount === 0) {
throw error;
}
if (opts.standardMessage === true && chunks.length > 1) {
// Framed prefixes are transport-only: the peer drops them unless the
// end frame arrives. Retire the batch before an unframed fallback.
const abortText = buildTelegramStandardFragmentAbort(chunks[0]?.text ?? "");
if (abortText) {
await sendTelegramTextChunk(
{ text: abortText, plainText: abortText },
buildTextParams(false),
).catch(() => undefined);
}
if (!priorVisibleDelivery) {
throw error;
}
}
throw markTelegramDeliveryErrorVisible(error);
}
}
if (isFramedStandardBatch && lastContextMessage && lastContextMessageId != null) {
const logicalText = chunks
.map((chunk) => stripTelegramStandardFragmentMarker(chunk.plainText ?? chunk.text))
.join("");
// Transport is complete once the end frame lands. Context persistence must
// not turn that visible logical send into a retryable delivery failure.
await recordOutboundMessageForPromptContext({
cfg,
account,
chatId,
message: res,
messageId,
text: chunk.plainText,
...(acceptedParams?.message_thread_id !== undefined
? { messageThreadId: acceptedParams.message_thread_id }
message: { ...lastContextMessage, text: logicalText },
messageId: lastContextMessageId,
text: logicalText,
...(lastAcceptedParams?.message_thread_id !== undefined
? { messageThreadId: lastAcceptedParams.message_thread_id }
: {}),
});
lastMessageId = String(messageId);
lastChatId = String(res?.chat?.id ?? chatId);
lastAcceptedParams = acceptedParams;
sentChunkCount += 1;
}).catch(() => undefined);
}
if (lastMessageId) {
logTelegramOutboundSendOk({
@@ -814,6 +875,13 @@ export async function sendMessageTelegram(
};
const buildChunkedTextPlan = (rawText: string, context: string): TelegramTextChunk[] => {
if (opts.standardMessage === true) {
return buildTelegramStandardTextChunks(rawText, { tableMode }).map((chunk) => ({
text: chunk.plainText,
plainText: chunk.plainText,
...(chunk.htmlText ? { htmlText: chunk.htmlText } : {}),
}));
}
const htmlText = renderHtmlText(rawText);
const fallbackText = textMode === "html" ? telegramHtmlToPlainTextFallback(htmlText) : rawText;
let htmlChunks: string[];
@@ -825,17 +893,21 @@ export async function sendMessageTelegram(
error,
)}`,
);
return splitTelegramPlainTextChunks(fallbackText, 4000).map((plainText) => ({ plainText }));
return splitTelegramPlainTextChunks(fallbackText, 4000).map((plainText) => ({
text: plainText,
plainText,
}));
}
const fixedPlainTextChunks = splitTelegramPlainTextChunks(fallbackText, 4000);
if (fixedPlainTextChunks.length > htmlChunks.length) {
logVerbose(
`telegram ${context} plain-text fallback needs more chunks than HTML; sending plain text`,
);
return fixedPlainTextChunks.map((plainText) => ({ plainText }));
return fixedPlainTextChunks.map((plainText) => ({ text: plainText, plainText }));
}
const plainTextChunks = splitTelegramPlainTextFallback(fallbackText, htmlChunks.length, 4000);
return htmlChunks.map((htmlTextLocal, index) => ({
text: plainTextChunks[index] ?? htmlTextLocal,
htmlText: htmlTextLocal,
plainText: plainTextChunks[index] ?? htmlTextLocal,
}));
@@ -1145,8 +1217,16 @@ export async function sendMessageTelegram(
// If text was too long for a caption, send it as a separate follow-up message.
// Use HTML conversion so markdown renders like captions.
if (needsSeparateText && followUpText) {
const textResult = await sendChunkedText(followUpText, "text follow-up send");
return { messageId: textResult.messageId, chatId: resolvedChatId };
try {
const textResult = await sendTelegramTextChunks(
buildChunkedTextPlan(followUpText),
"text follow-up send",
true,
);
return { messageId: textResult.messageId, chatId: resolvedChatId };
} catch (error) {
throw markTelegramDeliveryErrorVisible(error);
}
}
return { messageId: String(mediaMessageId), chatId: resolvedChatId };
+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;
+10 -1
View File
@@ -1,8 +1,9 @@
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
import type { ReplyToMode } from "../config/types.js";
/** Public option types for reply generation callbacks, streaming, and delivery policy. */
import type { ImageContent } from "../llm/types.js";
import type { PromptImageOrderEntry } from "../media/prompt-image-order.js";
import type { UserTurnTranscriptRecorder } from "../sessions/user-turn-transcript.types.js";
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
import type { ReplyPayload } from "./reply-payload.js";
import type { TypingController } from "./reply/typing.js";
@@ -220,6 +221,14 @@ export type GetReplyOptions = {
queuedDeliveryCorrelations?: QueuedReplyDeliveryCorrelation[];
/** Tracks ownership transfer when this turn later drains as a queued followup. */
queuedFollowupLifecycle?: QueuedReplyLifecycle;
/** Applies source-channel delivery metadata when a queued follow-up later drains. */
queuedDeliveryPayloadTransform?: (payload: ReplyPayload) => ReplyPayload;
/** Preserves source-channel reply fan-out policy for queued delivery. */
queuedDeliveryReplyToMode?: ReplyToMode;
/** Commits source-channel delivery state after a queued payload is visibly routed. */
queuedDeliveryPayloadDidDeliver?: (payload: ReplyPayload) => void;
/** Re-establishes source-owned async context while a queued follow-up drains. */
queuedExecutionContext?: <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
@@ -29,6 +29,8 @@ export type ReplyPayload = {
question: string;
};
replyToId?: string;
/** Internal reply-policy provenance; implicit ids honor single-use reply modes. */
replyToIdSource?: "explicit" | "implicit";
replyToTag?: boolean;
/** True when [[reply_to_current]] was present but not yet mapped to a message id. */
replyToCurrent?: boolean;
@@ -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 })),
}));
@@ -173,7 +176,11 @@ async function expectVisibleChatBlockRoutesToAccount(
describe("createAcpDispatchDeliveryCoordinator", () => {
beforeEach(() => {
deliveryMocks.routeReply.mockClear();
deliveryMocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock-message" });
deliveryMocks.routeReply.mockResolvedValue({
ok: true,
delivered: true,
messageId: "mock-message",
});
deliveryMocks.runMessageAction.mockClear();
deliveryMocks.runMessageAction.mockResolvedValue({ ok: true as const });
channelPluginMocks.getChannelPlugin.mockClear();
@@ -916,6 +923,36 @@ describe("createAcpDispatchDeliveryCoordinator", () => {
expect(coordinator.getRoutedCounts().block).toBe(1);
});
it("records partial routed block delivery as visible and failed without fallback", async () => {
deliveryMocks.routeReply.mockResolvedValueOnce({
ok: false,
delivered: true,
partialFailure: true,
error: "second chunk failed",
messageId: "visible-1",
});
const coordinator = createVisibleChatAcpCoordinator(createAcpTestConfig());
const delivered = await coordinator.deliver("block", { text: "hello" }, { skipTts: true });
expect(delivered).toBe(true);
expect(coordinator.hasDeliveredVisibleText()).toBe(true);
expect(coordinator.hasFailedVisibleTextDelivery()).toBe(true);
expect(coordinator.getRoutedCounts().block).toBe(1);
});
it("does not count a successful routed no-op as ACP delivery", async () => {
deliveryMocks.routeReply.mockResolvedValueOnce({ ok: true, delivered: false });
const coordinator = createVisibleChatAcpCoordinator(createAcpTestConfig());
const delivered = await coordinator.deliver("block", { text: "hello" }, { skipTts: true });
expect(delivered).toBe(false);
expect(coordinator.hasDeliveredVisibleText()).toBe(false);
expect(coordinator.hasFailedVisibleTextDelivery()).toBe(false);
expect(coordinator.getRoutedCounts().block).toBe(0);
});
it("treats hook-suppressed routed ACP block text as handled", async () => {
deliveryMocks.routeReply.mockResolvedValueOnce({
ok: true,
+12 -1
View File
@@ -160,6 +160,7 @@ type AcpDispatchDeliveryState = {
deliveredFinalReply: boolean;
deliveredVisibleText: boolean;
failedVisibleTextDelivery: boolean;
requiresVisibleTextFallback: boolean;
queuedDirectVisibleTextDeliveries: number;
settledDirectVisibleText: boolean;
routedCounts: Record<ReplyDispatchKind, number>;
@@ -182,6 +183,7 @@ export type AcpDispatchDeliveryCoordinator = {
hasDeliveredFinalReply: () => boolean;
hasDeliveredVisibleText: () => boolean;
hasFailedVisibleTextDelivery: () => boolean;
requiresVisibleTextFallback: () => boolean;
getRoutedCounts: () => Record<ReplyDispatchKind, number>;
applyRoutedCounts: (counts: Record<ReplyDispatchKind, number>) => void;
};
@@ -248,6 +250,7 @@ export function createAcpDispatchDeliveryCoordinator(params: {
deliveredFinalReply: false,
deliveredVisibleText: false,
failedVisibleTextDelivery: false,
requiresVisibleTextFallback: false,
queuedDirectVisibleTextDeliveries: 0,
settledDirectVisibleText: false,
routedCounts: {
@@ -279,6 +282,7 @@ export function createAcpDispatchDeliveryCoordinator(params: {
const failedVisibleCount = failedCounts.block + failedCounts.final;
if (failedVisibleCount > 0) {
state.failedVisibleTextDelivery = true;
state.requiresVisibleTextFallback = true;
}
if (state.queuedDirectVisibleTextDeliveries > failedVisibleCount) {
state.deliveredVisibleText = true;
@@ -455,11 +459,13 @@ export function createAcpDispatchDeliveryCoordinator(params: {
if (!result.ok) {
if (tracksVisibleText) {
state.failedVisibleTextDelivery = true;
if (!result.delivered) {
state.requiresVisibleTextFallback = true;
}
}
logVerbose(
`dispatch-acp: route-reply (acp/${kind}) failed: ${result.error ?? "unknown error"}`,
);
return false;
}
if (result.suppressed) {
if (kind === "final") {
@@ -470,6 +476,9 @@ export function createAcpDispatchDeliveryCoordinator(params: {
}
return true;
}
if (!result.delivered) {
return false;
}
if (kind === "tool" && meta?.toolCallId && result.messageId) {
state.toolMessageByCallId.set(meta.toolCallId, {
channel: params.originatingChannel,
@@ -513,6 +522,7 @@ export function createAcpDispatchDeliveryCoordinator(params: {
state.settledDirectVisibleText = false;
} else if (!delivered && tracksVisibleText) {
state.failedVisibleTextDelivery = true;
state.requiresVisibleTextFallback = true;
}
if (kind === "block" && delivered) {
hasPendingDirectBlockReplyDelivery = true;
@@ -532,6 +542,7 @@ export function createAcpDispatchDeliveryCoordinator(params: {
hasDeliveredFinalReply: () => state.deliveredFinalReply,
hasDeliveredVisibleText: () => state.deliveredVisibleText,
hasFailedVisibleTextDelivery: () => state.failedVisibleTextDelivery,
requiresVisibleTextFallback: () => state.requiresVisibleTextFallback,
getRoutedCounts: () => ({ ...state.routedCounts }),
applyRoutedCounts: (counts) => {
counts.tool += state.routedCounts.tool;
+41 -6
View File
@@ -40,8 +40,13 @@ const policyMocks = vi.hoisted(() => ({
const routeMocks = vi.hoisted(() => ({
routeReply: vi.fn<
(_params: unknown) => Promise<{ ok: true; messageId: string } | { ok: false; error: string }>
>(async () => ({ ok: true, messageId: "mock" })),
(
_params: unknown,
) => Promise<
| { ok: true; delivered: true; messageId: string }
| { ok: false; error: string; delivered?: boolean; partialFailure?: boolean }
>
>(async () => ({ ok: true, delivered: true, messageId: "mock" })),
}));
const channelPluginMocks = vi.hoisted(() => ({
@@ -448,7 +453,7 @@ describe("tryDispatchAcpReply", () => {
policyMocks.resolveAcpAgentPolicyError.mockReset();
policyMocks.resolveAcpAgentPolicyError.mockReturnValue(null);
routeMocks.routeReply.mockReset();
routeMocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" });
routeMocks.routeReply.mockResolvedValue({ ok: true, delivered: true, messageId: "mock" });
channelPluginMocks.getChannelPlugin.mockClear();
messageActionMocks.runMessageAction.mockReset();
messageActionMocks.runMessageAction.mockResolvedValue({ ok: true as const });
@@ -578,7 +583,11 @@ describe("tryDispatchAcpReply", () => {
it("edits ACP tool lifecycle updates in place when supported", async () => {
setReadyAcpResolution();
mockToolLifecycleTurn("call-1");
routeMocks.routeReply.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-1" });
routeMocks.routeReply.mockResolvedValueOnce({
ok: true,
delivered: true,
messageId: "tool-msg-1",
});
const { dispatcher } = createDispatcher();
await runDispatch({
@@ -599,8 +608,12 @@ describe("tryDispatchAcpReply", () => {
setReadyAcpResolution();
mockToolLifecycleTurn("call-2");
routeMocks.routeReply
.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2" })
.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2-fallback" });
.mockResolvedValueOnce({ ok: true, delivered: true, messageId: "tool-msg-2" })
.mockResolvedValueOnce({
ok: true,
delivered: true,
messageId: "tool-msg-2-fallback",
});
messageActionMocks.runMessageAction.mockRejectedValueOnce(new Error("edit unsupported"));
const { dispatcher } = createDispatcher();
@@ -1687,6 +1700,28 @@ describe("tryDispatchAcpReply", () => {
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
});
it("does not replay a routed ACP block after partial visible delivery", async () => {
setReadyAcpResolution();
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
routeMocks.routeReply.mockResolvedValue({
ok: false,
delivered: true,
partialFailure: true,
error: "second chunk failed",
});
mockRoutedTextTurn("partially visible block");
const { dispatcher } = createDispatcher();
await runDispatch({
bodyForAgent: "run acp",
dispatcher,
shouldRouteToOriginating: true,
});
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
expect(routePayload().text).toBe("partially visible block");
});
it("routes default ACP text as one final reply to Discord", async () => {
setReadyAcpResolution();
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
+2 -2
View File
@@ -267,7 +267,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,
@@ -329,7 +329,7 @@ async function finalizeAcpTurnOutput(params: {
accumulatedVisibleBlockText.trim().length > 0 &&
!finalMediaDelivered &&
!params.delivery.hasDeliveredFinalReply() &&
(!params.delivery.hasDeliveredVisibleText() || params.delivery.hasFailedVisibleTextDelivery());
(!params.delivery.hasDeliveredVisibleText() || params.delivery.requiresVisibleTextFallback());
if (shouldDeliverTextFallback) {
const delivered = await params.delivery.deliver(
"final",
@@ -67,7 +67,11 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
resetReplyRunRegistry();
setDiscordTestRegistry();
resetInboundDedupe();
mocks.routeReply.mockReset().mockResolvedValue({ ok: true, messageId: "mock" });
mocks.routeReply.mockReset().mockResolvedValue({
ok: true,
delivered: true,
messageId: "mock",
});
mocks.tryFastAbortFromMessage.mockReset().mockResolvedValue({
handled: false,
aborted: false,
@@ -205,7 +209,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({
existing: sessionStoreMocks.currentEntry,
});
mocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" });
mocks.routeReply.mockResolvedValue({ ok: true, delivered: true, messageId: "mock" });
const result = await dispatchReplyFromConfig({
ctx: createHookCtx(),
@@ -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();
+14 -6
View File
@@ -1699,8 +1699,16 @@ export async function dispatchReplyFromConfig(
});
};
const isRoutedReplyDelivered = (result: { ok: boolean; suppressed?: boolean }) =>
result.ok && result.suppressed !== true;
const isRoutedReplyDelivered = (result: {
ok: boolean;
delivered?: boolean;
suppressed?: boolean;
}) => result.delivered === true;
const isRoutedReplyHandled = (result: {
ok: boolean;
delivered?: boolean;
suppressed?: boolean;
}) => isRoutedReplyDelivered(result) || result.suppressed === true;
/**
* Helper to send a payload via route-reply (async).
@@ -1746,7 +1754,7 @@ export async function dispatchReplyFromConfig(
`dispatch-from-config: route-reply (plugin binding notice) failed: ${result.error ?? "unknown error"}`,
);
}
return result.ok;
return isRoutedReplyHandled(result);
}
markInboundDedupeReplayUnsafe();
return mode === "additive"
@@ -2159,7 +2167,7 @@ export async function dispatchReplyFromConfig(
} satisfies ReplyPayload;
const result = await routeReplyToOriginating(payload);
if (result) {
queuedFinal = result.ok;
queuedFinal = isRoutedReplyHandled(result);
if (isRoutedReplyDelivered(result)) {
routedFinalCount += 1;
}
@@ -2377,7 +2385,7 @@ export async function dispatchReplyFromConfig(
});
}
return {
queuedFinal: result.ok,
queuedFinal: isRoutedReplyHandled(result),
routedFinalCount: isRoutedReplyDelivered(result) ? 1 : 0,
};
}
@@ -3437,7 +3445,7 @@ export async function dispatchReplyFromConfig(
kind: "final",
});
if (result) {
queuedFinal = result.ok || queuedFinal;
queuedFinal = isRoutedReplyHandled(result) || queuedFinal;
if (isRoutedReplyDelivered(result)) {
routedFinalCount += 1;
}
+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();
@@ -4114,8 +4115,160 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
expect(onBlockReply).not.toHaveBeenCalled();
});
it("commits queued delivery state only after a payload survives routing", async () => {
routeReplyMock.mockResolvedValue({ ok: true, delivered: true });
resolveProviderFollowupFallbackRouteMock.mockImplementation(
(params: { context?: { payload?: ReplyPayload } }) =>
params.context?.payload?.text === "drop me"
? { route: "drop", reason: "already delivered out of band" }
: undefined,
);
let implicitReplyAvailable = true;
const queuedDeliveryPayloadTransform = vi.fn((payload: ReplyPayload) =>
implicitReplyAvailable
? { ...payload, replyToId: "root", replyToIdSource: "implicit" as const }
: payload,
);
const queuedDeliveryPayloadDidDeliver = vi.fn((payload: ReplyPayload) => {
if (payload.replyToIdSource === "implicit") {
implicitReplyAvailable = false;
}
});
await runMessagingCase({
agentResult: { payloads: [{ text: "drop me" }, { text: "deliver me" }] },
queued: {
...baseQueuedRun("webchat"),
originatingChannel: "discord",
originatingTo: "channel:C1",
queuedDeliveryPayloadTransform,
queuedDeliveryReplyToMode: "first",
queuedDeliveryPayloadDidDeliver,
} as FollowupRun,
});
expect(queuedDeliveryPayloadTransform).toHaveBeenCalledTimes(2);
expect(routeReplyMock).toHaveBeenCalledTimes(1);
expect(requireMockCallArg(routeReplyMock, 0).payload).toMatchObject({
text: "deliver me",
replyToId: "root",
replyToIdSource: "implicit",
});
expect(requireMockCallArg(routeReplyMock, 0).replyToMode).toBe("first");
expect(queuedDeliveryPayloadDidDeliver).toHaveBeenCalledTimes(1);
});
it("does not commit queued delivery state for a successful no-send route", async () => {
routeReplyMock
.mockResolvedValueOnce({ ok: true, delivered: false })
.mockResolvedValueOnce({ ok: true, delivered: true });
let implicitReplyAvailable = true;
const queuedDeliveryPayloadTransform = vi.fn((payload: ReplyPayload) =>
implicitReplyAvailable
? { ...payload, replyToId: "root", replyToIdSource: "implicit" as const }
: payload,
);
const queuedDeliveryPayloadDidDeliver = vi.fn((payload: ReplyPayload) => {
if (payload.replyToIdSource === "implicit") {
implicitReplyAvailable = false;
}
});
await runMessagingCase({
agentResult: { payloads: [{ text: "hidden" }, { text: "visible" }] },
queued: {
...baseQueuedRun("webchat"),
originatingChannel: "discord",
originatingTo: "channel:C1",
queuedDeliveryPayloadTransform,
queuedDeliveryPayloadDidDeliver,
} as FollowupRun,
});
expect(routeReplyMock).toHaveBeenCalledTimes(2);
expect(requireMockCallArg(routeReplyMock, 1).payload).toMatchObject({
text: "visible",
replyToId: "root",
replyToIdSource: "implicit",
});
expect(queuedDeliveryPayloadDidDeliver).toHaveBeenCalledTimes(1);
expect(queuedDeliveryPayloadDidDeliver).toHaveBeenCalledWith(
expect.objectContaining({ text: "visible" }),
);
});
it("commits queued delivery state without fallback after a partial send", async () => {
routeReplyMock.mockResolvedValue({
ok: false,
delivered: true,
partialFailure: true,
error: "second chunk failed",
});
const queuedDeliveryPayloadDidDeliver = vi.fn();
const { onBlockReply } = await runMessagingCase({
agentResult: { payloads: [{ text: "partially visible" }] },
queued: {
...baseQueuedRun("discord"),
originatingChannel: "discord",
originatingTo: "channel:C1",
queuedDeliveryPayloadDidDeliver,
} as FollowupRun,
});
expect(queuedDeliveryPayloadDidDeliver).toHaveBeenCalledTimes(1);
expect(onBlockReply).not.toHaveBeenCalled();
});
it("does not report cross-channel failure after an earlier partial visible delivery", async () => {
routeReplyMock
.mockResolvedValueOnce({
ok: false,
delivered: true,
partialFailure: true,
error: "second chunk failed",
})
.mockResolvedValueOnce({ ok: false, delivered: false, error: "provider unavailable" });
const queuedDeliveryPayloadDidDeliver = vi.fn();
const { onBlockReply } = await runMessagingCase({
agentResult: { payloads: [{ text: "partially visible" }, { text: "fully failed" }] },
queued: {
...baseQueuedRun("webchat"),
originatingChannel: "discord",
originatingTo: "channel:C1",
queuedDeliveryPayloadDidDeliver,
} as FollowupRun,
});
expect(queuedDeliveryPayloadDidDeliver).toHaveBeenCalledTimes(1);
expect(onBlockReply).not.toHaveBeenCalled();
});
it("does not commit queued delivery state when dispatcher only accepts the payload", async () => {
resolveProviderFollowupFallbackRouteMock.mockReturnValue({ route: "dispatcher" });
const queuedDeliveryPayloadDidDeliver = vi.fn();
const { onBlockReply } = await runMessagingCase({
agentResult: { payloads: [{ text: "dispatcher queued" }] },
queued: {
...baseQueuedRun("webchat"),
queuedDeliveryPayloadDidDeliver,
} as FollowupRun,
});
expect(onBlockReply).toHaveBeenCalledWith(
expect.objectContaining({ text: "dispatcher queued" }),
);
expect(queuedDeliveryPayloadDidDeliver).not.toHaveBeenCalled();
});
it("suppresses exact NO_REPLY followups without origin or dispatcher delivery", async () => {
const typing = createMockTypingController();
const queuedDeliveryPayloadTransform = vi.fn((payload: ReplyPayload) => ({
...payload,
channelData: { telegram: { standardMessage: true } },
}));
runEmbeddedAgentMock.mockResolvedValueOnce({
payloads: [{ text: ` ${DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText} ` }],
meta: {},
@@ -4126,9 +4279,16 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
defaultModel: "anthropic/claude-opus-4-6",
});
await runner(createQueuedRun({ originatingChannel: undefined, originatingTo: undefined }));
await runner(
createQueuedRun({
originatingChannel: undefined,
originatingTo: undefined,
queuedDeliveryPayloadTransform,
}),
);
expect(routeReplyMock).not.toHaveBeenCalled();
expect(queuedDeliveryPayloadTransform).not.toHaveBeenCalled();
expect(typing.markRunComplete).toHaveBeenCalledTimes(1);
expect(typing.markDispatchIdle).toHaveBeenCalledTimes(1);
});
+48 -14
View File
@@ -394,7 +394,12 @@ export function createFollowupRunner(params: {
}
await opts.onBlockReply(payload);
};
for (const payload of sendablePayloads) {
for (const sourcePayload of sendablePayloads) {
// Transform at routing time so channel-owned single-use state observes the
// previous payload's actual delivery outcome, not just its queue position.
const payload = queued.queuedDeliveryPayloadTransform
? queued.queuedDeliveryPayloadTransform(sourcePayload)
: sourcePayload;
const providerRoute = deliveryPlan.resolveFollowupRoute({
payload,
originatingChannel,
@@ -437,6 +442,7 @@ export function createFollowupRunner(params: {
requesterSenderUsername: queued.run.senderUsername,
requesterSenderE164: queued.run.senderE164,
threadId: queued.originatingThreadId,
replyToMode: queued.queuedDeliveryReplyToMode,
cfg: runtimeConfig,
mirror: hasTranscriptOwner ? false : options.mirror,
replyKind,
@@ -445,22 +451,43 @@ export function createFollowupRunner(params: {
if (!result.ok) {
const errorMsg = result.error ?? "unknown error";
logVerbose(`followup queue: route-reply failed: ${errorMsg}`);
const provider = resolveOriginMessageProvider({
provider: queued.run.messageProvider,
});
const origin = resolveOriginMessageProvider({
originatingChannel,
});
if (opts?.onBlockReply) {
if (origin && origin === provider) {
await sendDispatcherPayload(payload);
} else {
crossChannelRouteFailureNeedsNotice = true;
if (result.delivered) {
const provider = resolveOriginMessageProvider({
provider: queued.run.messageProvider,
});
const origin = resolveOriginMessageProvider({
originatingChannel,
});
if (origin && provider && origin !== provider) {
routedAnyCrossChannelPayloadToOrigin = true;
}
defaultRuntime.error?.(
`followup queue: route-reply partially failed after visible delivery: ${errorMsg}`,
);
queued.queuedDeliveryPayloadDidDeliver?.(payload);
} else {
defaultRuntime.error?.(`followup queue: route-reply failed: ${errorMsg}`);
const provider = resolveOriginMessageProvider({
provider: queued.run.messageProvider,
});
const origin = resolveOriginMessageProvider({
originatingChannel,
});
if (opts?.onBlockReply) {
if (origin && origin === provider) {
await sendDispatcherPayload(payload);
} else {
crossChannelRouteFailureNeedsNotice = true;
}
} else {
defaultRuntime.error?.(`followup queue: route-reply failed: ${errorMsg}`);
}
}
} else {
if (result.partialFailure) {
defaultRuntime.error?.(
`followup queue: route-reply partially failed after visible delivery: ${result.error ?? "unknown error"}`,
);
}
const provider = resolveOriginMessageProvider({
provider: queued.run.messageProvider,
});
@@ -470,6 +497,9 @@ export function createFollowupRunner(params: {
if (origin && provider && origin !== provider) {
routedAnyCrossChannelPayloadToOrigin = true;
}
if (result.delivered) {
queued.queuedDeliveryPayloadDidDeliver?.(payload);
}
}
} else if (deliveryRoute === "dispatcher") {
await sendDispatcherPayload(payload);
@@ -490,7 +520,7 @@ export function createFollowupRunner(params: {
}
};
return async (queued: FollowupRun) => {
const runQueuedFollowup = async (queued: FollowupRun) => {
if (isFollowupRunAborted(queued)) {
completeFollowupRunLifecycle(queued);
typing.markRunComplete();
@@ -1484,4 +1514,8 @@ export function createFollowupRunner(params: {
typing.markDispatchIdle();
}
};
return async (queued: FollowupRun) =>
queued.queuedExecutionContext
? await queued.queuedExecutionContext(() => runQueuedFollowup(queued))
: await runQueuedFollowup(queued);
}
@@ -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
@@ -1295,6 +1295,10 @@ export async function runPreparedReply(
...(queuedFollowupAbortSignal ? { abortSignal: queuedFollowupAbortSignal } : {}),
deliveryCorrelations: opts?.queuedDeliveryCorrelations,
queuedLifecycle: opts?.queuedFollowupLifecycle,
queuedDeliveryPayloadTransform: opts?.queuedDeliveryPayloadTransform,
queuedDeliveryReplyToMode: opts?.queuedDeliveryReplyToMode,
queuedDeliveryPayloadDidDeliver: opts?.queuedDeliveryPayloadDidDeliver,
queuedExecutionContext: opts?.queuedExecutionContext,
messageId: sessionCtx.MessageSidFull ?? sessionCtx.MessageSid,
summaryLine: baseBodyTrimmedRaw,
enqueuedAt: Date.now(),
+41 -2
View File
@@ -14,6 +14,8 @@ import { createUserTurnTranscriptRecorder } from "../../../sessions/user-turn-tr
import { resolveGlobalMap } from "../../../shared/global-singleton.js";
import {
buildCollectPrompt,
buildQueueSummaryLine,
buildQueueSummaryPrompt,
beginQueueDrain,
clearQueueSummaryState,
drainCollectQueueStep,
@@ -201,6 +203,15 @@ function splitCollectItemsByDeliveryContext(items: FollowupRun[]): FollowupRun[]
return groups;
}
function hasMixedSummarySourceContexts(items: FollowupRun[]): boolean {
return (
items.length > 1 &&
(hasCrossChannelItems(items, resolveCrossChannelKey) ||
splitCollectItemsByAuthorization(items).length > 1 ||
items.some(hasRuntimeOnlyFollowupMetadata))
);
}
function renderCollectItem(item: FollowupRun, idx: number): string {
const senderLabel =
item.run.senderName ?? item.run.senderUsername ?? item.run.senderId ?? item.run.senderE164;
@@ -233,6 +244,10 @@ type FollowupRuntimeMetadata = Pick<
| "abortSignal"
| "deliveryCorrelations"
| "queuedLifecycle"
| "queuedDeliveryPayloadTransform"
| "queuedDeliveryReplyToMode"
| "queuedDeliveryPayloadDidDeliver"
| "queuedExecutionContext"
>;
function hasCurrentTurnRuntimeMetadata(item: FollowupRun): boolean {
@@ -248,7 +263,11 @@ function hasRuntimeOnlyFollowupMetadata(item: FollowupRun): boolean {
hasCurrentTurnRuntimeMetadata(item) ||
item.abortSignal ||
item.deliveryCorrelations?.length ||
item.queuedLifecycle,
item.queuedLifecycle ||
item.queuedDeliveryPayloadTransform ||
item.queuedDeliveryReplyToMode ||
item.queuedDeliveryPayloadDidDeliver ||
item.queuedExecutionContext,
);
}
@@ -301,6 +320,18 @@ function collectRuntimeMetadata(
queuedLifecycle:
singletonOwner?.queuedLifecycle ??
(items.length === 1 ? lifecycleSource?.queuedLifecycle : undefined),
queuedDeliveryPayloadTransform:
singletonOwner?.queuedDeliveryPayloadTransform ??
(items.length === 1 ? items[0]?.queuedDeliveryPayloadTransform : undefined),
queuedDeliveryReplyToMode:
singletonOwner?.queuedDeliveryReplyToMode ??
(items.length === 1 ? items[0]?.queuedDeliveryReplyToMode : undefined),
queuedDeliveryPayloadDidDeliver:
singletonOwner?.queuedDeliveryPayloadDidDeliver ??
(items.length === 1 ? items[0]?.queuedDeliveryPayloadDidDeliver : undefined),
queuedExecutionContext:
singletonOwner?.queuedExecutionContext ??
(items.length === 1 ? items[0]?.queuedExecutionContext : undefined),
};
}
@@ -740,9 +771,17 @@ export function scheduleFollowupDrain(
// Debug: `pnpm test src/auto-reply/reply/reply-flow.test.ts`
// Check if messages span multiple channels.
// If so, process individually to preserve per-message routing.
// Retained overflow sources still own their original route and auth
// context. Drain live items first when combining them would deliver a
// summary through a different owner.
const summarySources = queue.summarySources ?? [];
const isCrossChannel =
hasCrossChannelItems(queue.items, resolveCrossChannelKey) ||
queue.items.some(hasRuntimeOnlyFollowupMetadata);
queue.items.some(hasRuntimeOnlyFollowupMetadata) ||
hasMixedSummarySourceContexts(summarySources) ||
summarySources.some(hasRuntimeOnlyFollowupMetadata) ||
(summarySources.length > 0 &&
hasCrossChannelItems([...queue.items, ...summarySources], resolveCrossChannelKey));
if (collectState.forceIndividualCollect && !isCrossChannel && queue.items.length > 1) {
collectState.forceIndividualCollect = false;
}
+5
View File
@@ -19,6 +19,7 @@ import type {
QueuedReplyLifecycle,
SourceReplyDeliveryMode,
} from "../../get-reply-options.types.js";
import type { ReplyPayload } from "../../reply-payload.js";
import type { OriginatingChannelType } from "../../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js";
@@ -63,6 +64,10 @@ export type FollowupRun = {
abortSignal?: AbortSignal;
deliveryCorrelations?: QueuedReplyDeliveryCorrelation[];
queuedLifecycle?: QueuedReplyLifecycle;
queuedDeliveryPayloadTransform?: (payload: ReplyPayload) => ReplyPayload;
queuedDeliveryReplyToMode?: ReplyToMode;
queuedDeliveryPayloadDidDeliver?: (payload: ReplyPayload) => void;
queuedExecutionContext?: <T>(run: () => Promise<T>) => Promise<T>;
/** Provider message ID, when available (for deduplication). */
messageId?: string;
summaryLine?: string;
+41 -1
View File
@@ -182,6 +182,7 @@ async function expectSlackNoDelivery(
...overrides,
});
expect(res.ok).toBe(true);
expect(res.delivered).toBe(false);
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
return res;
}
@@ -573,6 +574,7 @@ describe("routeReply", () => {
expect(res).toEqual({
ok: true,
delivered: false,
suppressed: true,
reason: "cancelled_by_reply_payload_sending_hook",
});
@@ -587,6 +589,40 @@ describe("routeReply", () => {
});
});
it("reports visible delivery when a later batch part fails", async () => {
mocks.deliverOutboundPayloads.mockImplementationOnce(
async ({
onPayloadDeliveryOutcome,
}: {
onPayloadDeliveryOutcome?: (outcome: unknown) => void;
}) => {
onPayloadDeliveryOutcome?.({
index: 0,
status: "failed",
error: new Error("second chunk failed"),
sentBeforeError: true,
stage: "platform_send",
});
return [{ channel: "telegram", messageId: "visible-1" }];
},
);
const res = await routeReply({
payload: { text: "hello" },
channel: "telegram",
to: "chat-1",
cfg: {} as never,
});
expect(res).toMatchObject({
ok: false,
delivered: true,
partialFailure: true,
messageId: "visible-1",
});
expect(res.error).toContain("second chunk failed");
});
it("suppresses routed delivery when reply payload hooks cancel", async () => {
mocks.deliverOutboundPayloads.mockImplementationOnce(
async ({
@@ -612,6 +648,7 @@ describe("routeReply", () => {
expect(res).toEqual({
ok: true,
delivered: false,
suppressed: true,
reason: "cancelled_by_reply_payload_sending_hook",
});
@@ -643,6 +680,7 @@ describe("routeReply", () => {
expect(res).toEqual({
ok: true,
delivered: false,
suppressed: true,
reason: "empty_after_reply_payload_sending_hook",
});
@@ -820,17 +858,19 @@ describe("routeReply", () => {
expect(lastDeliveryPayload().text).toBe("BTW\nQuestion: what is 17 * 19?\n\n323");
});
it("passes replyToId to Telegram sends", async () => {
it("passes reply targeting policy to Telegram sends", async () => {
await routeReply({
payload: { text: "hi", replyToId: "123" },
channel: "telegram",
to: "telegram:123",
replyToMode: "first",
cfg: {} as never,
});
expectLastDeliveryFields({
channel: "telegram",
to: "telegram:123",
replyToId: "123",
replyToMode: "first",
});
});
+28 -7
View File
@@ -14,6 +14,7 @@ import { normalizeChatType } from "../../channels/chat-type.js";
import { getBundledChannelPlugin } from "../../channels/plugins/bundled.js";
import { getLoadedChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { normalizeChatChannelId } from "../../channels/registry.js";
import type { ReplyToMode } from "../../config/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
@@ -89,6 +90,8 @@ type RouteReplyParams = {
threadId?: string | number;
/** Reply policy fallback for delivery kinds that do not carry payload metadata. */
replyDelivery?: ReplyDeliveryContext;
/** Source reply fan-out policy for transports that split one payload. */
replyToMode?: ReplyToMode;
/** Config for provider-specific settings. */
cfg: OpenClawConfig;
/** Optional abort signal for cooperative cancellation. */
@@ -108,8 +111,12 @@ type RouteReplyParams = {
type RouteReplyResult = {
/** Whether the reply was sent successfully. */
ok: boolean;
/** Whether provider-visible delivery actually occurred. */
delivered: boolean;
/** True when a hook intentionally suppressed provider delivery. */
suppressed?: boolean;
/** True when part of the payload was visible before a later send failed. */
partialFailure?: boolean;
/** Suppression reason when delivery was intentionally skipped. */
reason?: "cancelled_by_reply_payload_sending_hook" | "empty_after_reply_payload_sending_hook";
/** Optional message ID from the provider. */
@@ -129,7 +136,7 @@ type RouteReplyResult = {
export async function routeReply(params: RouteReplyParams): Promise<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 =
@@ -167,7 +174,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
: undefined,
});
if (!normalized) {
return { ok: true };
return { ok: true, delivered: false };
}
const externalPayload: ReplyPayload = {
...normalized,
@@ -202,21 +209,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 payloadMetadata = getReplyPayloadMetadata(normalized);
@@ -296,6 +304,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
},
},
replyToId: resolvedReplyToId ?? null,
replyToMode: params.replyToMode,
threadId: resolvedThreadId,
session: outboundSession,
signal: abortSignal,
@@ -311,9 +320,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" ||
@@ -321,6 +340,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
) {
return {
ok: true,
delivered: false,
suppressed: true,
reason: send.reason,
};
@@ -328,11 +348,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
@@ -228,7 +228,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,
@@ -267,7 +267,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
@@ -1884,6 +1884,43 @@ describe("deliverOutboundPayloads", () => {
});
});
it("preserves adapter partial-delivery metadata without a message receipt", async () => {
const adapterError = Object.assign(new Error("later adapter chunk failed"), {
sentBeforeError: true,
visibleReplySent: true,
});
const sendPayload = vi.fn().mockRejectedValue(adapterError);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: {
deliveryMode: "direct",
sendText: vi.fn(),
sendMedia: vi.fn(),
sendPayload,
},
}),
},
]),
);
await expect(
deliverOutboundPayloads({
cfg: {},
channel: "matrix",
to: "!room",
payloads: [{ text: "long peer reply", channelData: { matrix: { mode: "notice" } } }],
}),
).rejects.toMatchObject({
sentBeforeError: true,
payloadOutcomes: [expect.objectContaining({ status: "failed", sentBeforeError: true })],
});
});
it("strips internal runtime scaffolding copied into rendered and normalized nested payloads", async () => {
const sendPayload = vi.fn().mockResolvedValue({
channel: "matrix" as const,
+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,
});
}
@@ -1962,7 +1967,12 @@ async function deliverOutboundPayloadsCore(
index: payloadIndex,
status: "failed",
error: err,
sentBeforeError: results.length > 0,
sentBeforeError:
results.length > 0 ||
(typeof err === "object" &&
err !== null &&
"sentBeforeError" in err &&
err.sentBeforeError === true),
stage: "platform_send",
});
errorDeliveryDiagnostics(err);
+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) {