mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
fix(telegram): gate rich messages behind opt-in (#93279)
Restore readable standard Telegram text delivery by default after Bot API 10.1 rich messages rendered as unsupported in current clients. Keep native rich tables and structured messages available through the account-level richMessages opt-in, with account-aware capability advertising and documented structural limits. Fixes #93263.
This commit is contained in:
@@ -418,7 +418,19 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Rich message formatting">
|
||||
Outbound text uses Telegram rich messages.
|
||||
Outbound text uses standard Telegram HTML messages by default so replies remain readable across current Telegram clients.
|
||||
|
||||
Set `channels.telegram.richMessages: true` to opt into Bot API 10.1 rich messages:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
richMessages: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Markdown text is rendered through OpenClaw's Markdown IR and sent as Telegram rich HTML.
|
||||
- Explicit rich HTML payloads preserve supported Bot API 10.1 tags such as headings, tables, details, rich media, and formulas.
|
||||
@@ -426,6 +438,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
|
||||
This keeps model text away from Telegram Rich Markdown sigils, so currency like `$400-600K` is not parsed as math. Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
|
||||
|
||||
Rich messages require compatible Telegram clients. Some current Desktop, Web, Android, and third-party clients display accepted rich messages as unsupported, so keep this option disabled unless every client used with the bot can render them.
|
||||
|
||||
Link previews are enabled by default. `channels.telegram.linkPreview: false` skips automatic entity detection for rich text.
|
||||
|
||||
</Accordion>
|
||||
@@ -1081,7 +1095,7 @@ Primary reference: [Configuration reference - Telegram](/gateway/config-channels
|
||||
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
|
||||
- threading/replies: `replyToMode`
|
||||
- streaming: `streaming` (preview), `streaming.preview.toolProgress`, `blockStreaming`
|
||||
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
|
||||
- formatting/delivery: `textChunkLimit`, `chunkMode`, `richMessages`, `linkPreview`, `responsePrefix`
|
||||
- media/network: `mediaMaxMb`, `mediaGroupFlushMs`, `timeoutSeconds`, `pollingStallThresholdMs`, `retry`, `network.autoSelectFamily`, `network.dangerouslyAllowPrivateNetwork`, `proxy`
|
||||
- custom API root: `apiRoot` (Bot API root only; do not include `/bot<TOKEN>`)
|
||||
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
resolveTelegramOutboundClientTimeoutFloorSeconds,
|
||||
} from "./client-fetch.js";
|
||||
import { resolveTelegramTransport } from "./fetch.js";
|
||||
import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js";
|
||||
import { stringifyTelegramRawUpdateForLog } from "./raw-update-log.js";
|
||||
import { TELEGRAM_RICH_TEXT_LIMIT } from "./rich-message.js";
|
||||
import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js";
|
||||
@@ -290,11 +291,13 @@ export function createTelegramBotCore(
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
const groupHistories = new Map<string, HistoryEntry[]>();
|
||||
const telegramTextLimit =
|
||||
telegramCfg.richMessages === true ? TELEGRAM_RICH_TEXT_LIMIT : TELEGRAM_TEXT_CHUNK_LIMIT;
|
||||
const textLimit = Math.min(
|
||||
resolveTextChunkLimit(cfg, "telegram", account.accountId, {
|
||||
fallbackLimit: TELEGRAM_RICH_TEXT_LIMIT,
|
||||
fallbackLimit: telegramTextLimit,
|
||||
}),
|
||||
TELEGRAM_RICH_TEXT_LIMIT,
|
||||
telegramTextLimit,
|
||||
);
|
||||
const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
|
||||
const allowFrom = opts.allowFrom ?? telegramCfg.allowFrom;
|
||||
|
||||
@@ -649,6 +649,61 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders default draft previews with standard Telegram HTML", async () => {
|
||||
const draftStream = createDraftStream();
|
||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
await replyOptions?.onPartialReply?.({ text: "# Heading" });
|
||||
await dispatcherOptions.deliver({ text: "# Heading" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
},
|
||||
);
|
||||
deliverReplies.mockResolvedValue({ delivered: true });
|
||||
|
||||
await dispatchWithContext({ context: createContext() });
|
||||
|
||||
const params = expectDraftStreamParams({});
|
||||
const renderText = params.renderText as ((text: string) => Record<string, unknown>) | undefined;
|
||||
expect(renderText?.("# Heading")).toEqual({
|
||||
text: "Heading",
|
||||
parseMode: "HTML",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders rich draft previews only when enabled", async () => {
|
||||
resolveMarkdownTableMode.mockReturnValueOnce("block");
|
||||
const draftStream = createDraftStream();
|
||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
await replyOptions?.onPartialReply?.({
|
||||
text: "| A | B |\n| --- | --- |\n| 1 | 2 |",
|
||||
});
|
||||
await dispatcherOptions.deliver(
|
||||
{ text: "| A | B |\n| --- | --- |\n| 1 | 2 |" },
|
||||
{ kind: "final" },
|
||||
);
|
||||
return { queuedFinal: true };
|
||||
},
|
||||
);
|
||||
deliverReplies.mockResolvedValue({ delivered: true });
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
telegramCfg: { richMessages: true },
|
||||
});
|
||||
|
||||
const params = expectDraftStreamParams({ richMessages: true });
|
||||
const renderText = params.renderText as ((text: string) => Record<string, unknown>) | undefined;
|
||||
const preview = renderText?.("| A | B |\n| --- | --- |\n| 1 | 2 |");
|
||||
expect(preview?.richMessage).toEqual(
|
||||
expect.objectContaining({
|
||||
html: expect.stringContaining("<table>"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("recovers forum thread context from a topic-scoped session key", async () => {
|
||||
const recordInboundSession = vi.fn(async () => undefined);
|
||||
const oldHistoryKey = "-1003774691294:topic:1";
|
||||
@@ -1521,7 +1576,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
telegramCfg: { streaming: { mode: "partial" } },
|
||||
});
|
||||
|
||||
expectDraftStreamParams({ maxChars: 4096 });
|
||||
expectDraftStreamParams({ maxChars: 4000 });
|
||||
});
|
||||
|
||||
it("streams text-only finals into the answer message", async () => {
|
||||
|
||||
@@ -107,6 +107,7 @@ import {
|
||||
shouldSuppressTelegramError,
|
||||
} from "./error-policy.js";
|
||||
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
|
||||
import { renderTelegramHtmlText } from "./format.js";
|
||||
import { includesRecentTelegramGroupHistoryContext } from "./group-history-context.js";
|
||||
import { beginTelegramInboundEventDeliveryCorrelation } from "./inbound-event-delivery.js";
|
||||
import {
|
||||
@@ -116,6 +117,7 @@ import {
|
||||
type LaneDeliveryResult,
|
||||
type LaneName,
|
||||
} from "./lane-delivery.js";
|
||||
import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js";
|
||||
import { recordOutboundMessageForPromptContext } from "./outbound-message-context.js";
|
||||
import {
|
||||
createTelegramReasoningStepState,
|
||||
@@ -891,20 +893,29 @@ export const dispatchTelegramMessage = async ({
|
||||
const draftMaxChars =
|
||||
streamMode === "block"
|
||||
? Math.min(resolveTelegramDraftStreamingChunking(cfg, route.accountId).maxChars, textLimit)
|
||||
: Math.min(textLimit, TELEGRAM_RICH_TEXT_LIMIT);
|
||||
: Math.min(
|
||||
textLimit,
|
||||
telegramCfg.richMessages === true ? TELEGRAM_RICH_TEXT_LIMIT : TELEGRAM_TEXT_CHUNK_LIMIT,
|
||||
);
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: route.accountId,
|
||||
supportsBlockTables: true,
|
||||
});
|
||||
const renderStreamText = (text: string) => ({
|
||||
text,
|
||||
richMessage: buildTelegramRichMarkdown(text, {
|
||||
tableMode,
|
||||
skipEntityDetection: telegramCfg.linkPreview === false,
|
||||
}),
|
||||
supportsBlockTables: telegramCfg.richMessages === true,
|
||||
});
|
||||
const renderStreamText = (text: string): TelegramDraftPreview =>
|
||||
telegramCfg.richMessages === true
|
||||
? {
|
||||
text,
|
||||
richMessage: buildTelegramRichMarkdown(text, {
|
||||
tableMode,
|
||||
skipEntityDetection: telegramCfg.linkPreview === false,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
text: renderTelegramHtmlText(text, { tableMode }),
|
||||
parseMode: "HTML",
|
||||
};
|
||||
const accountBlockStreamingEnabled =
|
||||
resolveChannelStreamingBlockEnabled(telegramCfg) ??
|
||||
cfg.agents?.defaults?.blockStreamingDefault === "on";
|
||||
@@ -988,6 +999,7 @@ export const dispatchTelegramMessage = async ({
|
||||
maxChars: draftMaxChars,
|
||||
thread: threadSpec,
|
||||
replyToMessageId: draftReplyToMessageId,
|
||||
richMessages: telegramCfg.richMessages,
|
||||
minInitialChars: draftMinInitialChars,
|
||||
renderText: renderStreamText,
|
||||
onSupersededPreview: (superseded) => {
|
||||
@@ -1507,6 +1519,7 @@ export const dispatchTelegramMessage = async ({
|
||||
thread: threadSpec,
|
||||
tableMode,
|
||||
chunkMode,
|
||||
richMessages: telegramCfg.richMessages,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
replyQuoteMessageId,
|
||||
replyQuoteText,
|
||||
|
||||
@@ -703,6 +703,25 @@ describe("registerTelegramNativeCommands", () => {
|
||||
expect(replyAt(deliverParams).isError).toBe(true);
|
||||
});
|
||||
|
||||
it("uses rich messages for plugin command replies when enabled", async () => {
|
||||
const { handler } = registerPlugCommand({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
richMessages: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
registerOverrides: {
|
||||
telegramCfg: { richMessages: true } as TelegramAccountConfig,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(createPrivateCommandContext());
|
||||
|
||||
expect(firstDeliverRepliesParams().richMessages).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards topic-scoped binding context to Telegram plugin commands", async () => {
|
||||
const { handler } = registerPlugCommand();
|
||||
|
||||
|
||||
@@ -973,6 +973,7 @@ export const registerTelegramNativeCommands = ({
|
||||
tableMode: ReturnType<typeof resolveMarkdownTableMode>;
|
||||
chunkMode: TelegramChunkMode;
|
||||
linkPreview?: boolean;
|
||||
richMessages?: boolean;
|
||||
}) => ({
|
||||
cfg: params.cfg,
|
||||
chatId: String(params.chatId),
|
||||
@@ -992,6 +993,7 @@ export const registerTelegramNativeCommands = ({
|
||||
tableMode: params.tableMode,
|
||||
chunkMode: params.chunkMode,
|
||||
linkPreview: params.linkPreview,
|
||||
richMessages: params.richMessages,
|
||||
});
|
||||
const resolveCommandTargetSessionKey = (params: {
|
||||
runtimeCfg: OpenClawConfig;
|
||||
@@ -1209,6 +1211,7 @@ export const registerTelegramNativeCommands = ({
|
||||
tableMode,
|
||||
chunkMode,
|
||||
linkPreview: runtimeTelegramCfg.linkPreview,
|
||||
richMessages: runtimeTelegramCfg.richMessages,
|
||||
});
|
||||
let topicName: string | undefined;
|
||||
if (isForum && resolvedThreadId != null) {
|
||||
@@ -1431,6 +1434,7 @@ export const registerTelegramNativeCommands = ({
|
||||
tableMode,
|
||||
chunkMode,
|
||||
linkPreview: runtimeTelegramCfg.linkPreview,
|
||||
richMessages: runtimeTelegramCfg.richMessages,
|
||||
});
|
||||
const from = isGroup ? buildTelegramGroupFrom(chatId, threadSpec.id) : `telegram:${chatId}`;
|
||||
const to = `telegram:${chatId}`;
|
||||
|
||||
@@ -4,15 +4,15 @@ import {
|
||||
createOutboundPayloadPlan,
|
||||
projectOutboundPayloadPlanForDelivery,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { ReplyToMode } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { fireAndForgetHook } from "openclaw/plugin-sdk/hook-runtime";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "openclaw/plugin-sdk/hook-runtime";
|
||||
import type { MarkdownTableMode, ReplyToMode } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
buildCanonicalSentMessageHookContext,
|
||||
createInternalHookEvent,
|
||||
fireAndForgetHook,
|
||||
toInternalMessageSentContext,
|
||||
toPluginMessageContext,
|
||||
toPluginMessageSentEvent,
|
||||
triggerInternalHook,
|
||||
} from "openclaw/plugin-sdk/hook-runtime";
|
||||
import type { ReplyPayloadDelivery } from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
probeVideoDimensions,
|
||||
} from "openclaw/plugin-sdk/media-runtime";
|
||||
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import type { ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
@@ -31,13 +31,14 @@ 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 { renderTelegramHtmlText } from "../format.js";
|
||||
import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js";
|
||||
import {
|
||||
splitTelegramRichMessageTextChunks,
|
||||
TELEGRAM_RICH_TEXT_LIMIT,
|
||||
type TelegramRichTextChunk,
|
||||
} from "../rich-message.js";
|
||||
markdownToTelegramChunks,
|
||||
markdownToTelegramHtml,
|
||||
renderTelegramHtmlText,
|
||||
wrapFileReferencesInHtml,
|
||||
} from "../format.js";
|
||||
import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js";
|
||||
import { splitTelegramRichMessageTextChunks, TELEGRAM_RICH_TEXT_LIMIT } from "../rich-message.js";
|
||||
import { buildInlineKeyboard } from "../send.js";
|
||||
import { resolveTelegramVoiceSend } from "../voice.js";
|
||||
import {
|
||||
@@ -75,23 +76,58 @@ type TelegramReplyQuoteForSend = {
|
||||
entities?: unknown[];
|
||||
};
|
||||
|
||||
type ChunkTextFn = (markdown: string) => TelegramRichTextChunk[];
|
||||
type TelegramDeliveryTextChunk = {
|
||||
text: string;
|
||||
plainText: string;
|
||||
textMode: "html";
|
||||
};
|
||||
|
||||
type ChunkTextFn = (markdown: string) => TelegramDeliveryTextChunk[];
|
||||
|
||||
function buildChunkTextResolver(params: {
|
||||
textLimit: number;
|
||||
chunkMode: ChunkMode;
|
||||
tableMode?: MarkdownTableMode;
|
||||
richMessages?: boolean;
|
||||
skipEntityDetection?: boolean;
|
||||
}): ChunkTextFn {
|
||||
if (params.richMessages === true) {
|
||||
return (markdown: string) =>
|
||||
splitTelegramRichMessageTextChunks({
|
||||
text: markdown,
|
||||
textLimit: Math.min(params.textLimit, TELEGRAM_RICH_TEXT_LIMIT),
|
||||
textMode: "markdown",
|
||||
chunkMode: params.chunkMode,
|
||||
tableMode: params.tableMode,
|
||||
skipEntityDetection: params.skipEntityDetection,
|
||||
});
|
||||
}
|
||||
return (markdown: string) => {
|
||||
return splitTelegramRichMessageTextChunks({
|
||||
text: markdown,
|
||||
textLimit: params.textLimit,
|
||||
textMode: "markdown",
|
||||
chunkMode: params.chunkMode,
|
||||
tableMode: params.tableMode,
|
||||
skipEntityDetection: params.skipEntityDetection,
|
||||
});
|
||||
const markdownChunks =
|
||||
params.chunkMode === "newline"
|
||||
? chunkMarkdownTextWithMode(markdown, params.textLimit, params.chunkMode)
|
||||
: [markdown];
|
||||
const chunks: ReturnType<typeof markdownToTelegramChunks> = [];
|
||||
for (const chunk of markdownChunks) {
|
||||
const nested = markdownToTelegramChunks(chunk, params.textLimit, {
|
||||
tableMode: params.tableMode,
|
||||
});
|
||||
if (!nested.length && chunk) {
|
||||
chunks.push({
|
||||
html: wrapFileReferencesInHtml(
|
||||
markdownToTelegramHtml(chunk, { tableMode: params.tableMode, wrapFileRefs: false }),
|
||||
),
|
||||
text: chunk,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
chunks.push(...nested);
|
||||
}
|
||||
return chunks.map((chunk) => ({
|
||||
text: chunk.html,
|
||||
plainText: chunk.text,
|
||||
textMode: "html" as const,
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,9 +194,10 @@ async function deliverTextReply(params: {
|
||||
replyQuoteText?: string;
|
||||
replyQuotePosition?: number;
|
||||
replyQuoteEntities?: unknown[];
|
||||
richMessages?: boolean;
|
||||
tableMode?: MarkdownTableMode;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
tableMode?: MarkdownTableMode;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
@@ -189,6 +226,8 @@ async function deliverTextReply(params: {
|
||||
replyQuoteEntities: params.replyQuoteEntities,
|
||||
thread: params.thread,
|
||||
textMode: chunk.textMode,
|
||||
plainText: chunk.plainText,
|
||||
richMessages: params.richMessages,
|
||||
linkPreview: params.linkPreview,
|
||||
tableMode: params.tableMode,
|
||||
silent: params.silent,
|
||||
@@ -211,9 +250,10 @@ async function sendPendingFollowUpText(params: {
|
||||
chunkText: ChunkTextFn;
|
||||
text: string;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
richMessages?: boolean;
|
||||
tableMode?: MarkdownTableMode;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
tableMode?: MarkdownTableMode;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
@@ -231,6 +271,8 @@ async function sendPendingFollowUpText(params: {
|
||||
replyToMessageId,
|
||||
thread: params.thread,
|
||||
textMode: chunk.textMode,
|
||||
plainText: chunk.plainText,
|
||||
richMessages: params.richMessages,
|
||||
linkPreview: params.linkPreview,
|
||||
tableMode: params.tableMode,
|
||||
silent: params.silent,
|
||||
@@ -275,9 +317,10 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
replyQuotePosition?: number;
|
||||
replyQuoteEntities?: unknown[];
|
||||
thread?: TelegramThreadSpec | null;
|
||||
richMessages?: boolean;
|
||||
tableMode?: MarkdownTableMode;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
tableMode?: MarkdownTableMode;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyQuoteText?: string;
|
||||
}): Promise<number | undefined> {
|
||||
@@ -296,6 +339,8 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
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,
|
||||
@@ -319,6 +364,7 @@ async function deliverMediaReply(params: {
|
||||
runtime: RuntimeEnv;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
tableMode?: MarkdownTableMode;
|
||||
richMessages?: boolean;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaMaxBytes?: number;
|
||||
chunkText: ChunkTextFn;
|
||||
@@ -480,6 +526,8 @@ async function deliverMediaReply(params: {
|
||||
replyQuotePosition: params.replyQuotePosition,
|
||||
replyQuoteEntities: params.replyQuoteEntities,
|
||||
thread: params.thread,
|
||||
richMessages: params.richMessages,
|
||||
tableMode: params.tableMode,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup: params.replyMarkup,
|
||||
@@ -511,6 +559,8 @@ async function deliverMediaReply(params: {
|
||||
chunkText: params.chunkText,
|
||||
replyToId: undefined,
|
||||
thread: params.thread,
|
||||
richMessages: params.richMessages,
|
||||
tableMode: params.tableMode,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup: params.replyMarkup,
|
||||
@@ -560,9 +610,10 @@ async function deliverMediaReply(params: {
|
||||
chunkText: params.chunkText,
|
||||
text: pendingFollowUpText,
|
||||
replyMarkup: params.replyMarkup,
|
||||
richMessages: params.richMessages,
|
||||
tableMode: params.tableMode,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
tableMode: params.tableMode,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
@@ -693,6 +744,8 @@ export async function deliverReplies(params: {
|
||||
thread?: TelegramThreadSpec | null;
|
||||
tableMode?: MarkdownTableMode;
|
||||
chunkMode?: ChunkMode;
|
||||
/** Opt into Telegram Bot API 10.1 rich text delivery. */
|
||||
richMessages?: boolean;
|
||||
/** 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). */
|
||||
@@ -725,9 +778,13 @@ export async function deliverReplies(params: {
|
||||
const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false;
|
||||
const hasMessageSentHooks = hookRunner?.hasHooks("message_sent") ?? false;
|
||||
const chunkText = buildChunkTextResolver({
|
||||
textLimit: Math.min(params.textLimit, TELEGRAM_RICH_TEXT_LIMIT),
|
||||
textLimit:
|
||||
params.richMessages === true
|
||||
? Math.min(params.textLimit, TELEGRAM_RICH_TEXT_LIMIT)
|
||||
: Math.min(params.textLimit, 4000),
|
||||
chunkMode: params.chunkMode ?? "length",
|
||||
tableMode: params.tableMode,
|
||||
richMessages: params.richMessages,
|
||||
skipEntityDetection: params.linkPreview === false,
|
||||
});
|
||||
const candidateReplies: ReplyPayload[] = [];
|
||||
@@ -847,9 +904,10 @@ export async function deliverReplies(params: {
|
||||
replyQuoteText: replyQuote.text,
|
||||
replyQuotePosition: replyQuote.position,
|
||||
replyQuoteEntities: replyQuote.entities,
|
||||
richMessages: params.richMessages,
|
||||
tableMode: params.tableMode,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
tableMode: params.tableMode,
|
||||
replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress,
|
||||
@@ -863,6 +921,7 @@ export async function deliverReplies(params: {
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
tableMode: params.tableMode,
|
||||
richMessages: params.richMessages,
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
mediaMaxBytes: params.mediaMaxBytes,
|
||||
chunkText,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createTelegramRetryRunner } from "openclaw/plugin-sdk/retry-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { withTelegramApiErrorLogging } from "../api-logging.js";
|
||||
import { markdownToTelegramHtml } from "../format.js";
|
||||
import { isSafeToRetrySendError, isTelegramRateLimitError } from "../network-errors.js";
|
||||
import {
|
||||
buildTelegramSendParams,
|
||||
@@ -22,6 +23,8 @@ import type { TelegramThreadSpec } from "./helpers.js";
|
||||
|
||||
export { buildTelegramSendParams } from "../reply-parameters.js";
|
||||
|
||||
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
||||
const EMPTY_TEXT_ERR_RE = /message text is empty/i;
|
||||
const QUOTE_PARAM_RE = /\bquote not found\b|\bQUOTE_TEXT_INVALID\b|\bquote text invalid\b/i;
|
||||
const GrammyErrorCtor: typeof GrammyError | undefined =
|
||||
typeof GrammyError === "function" ? GrammyError : undefined;
|
||||
@@ -73,14 +76,14 @@ export async function sendTelegramWithThreadFallback<T>(params: {
|
||||
} catch (err) {
|
||||
if (hasNativeQuote && isTelegramQuoteParamError(err)) {
|
||||
params.runtime.log?.(
|
||||
`telegram ${params.operation}: native quote rejected; retrying without quote text`,
|
||||
`telegram ${params.operation}: native quote rejected; retrying with legacy reply_to_message_id`,
|
||||
);
|
||||
const removeNativeQuoteParam =
|
||||
params.removeNativeQuoteParam ?? removeTelegramNativeQuoteParam;
|
||||
return await sendTelegramWithThreadFallback({
|
||||
...params,
|
||||
operation: `${params.operation} (reply retry)`,
|
||||
requestParams: removeNativeQuoteParam(params.requestParams),
|
||||
operation: `${params.operation} (legacy reply retry)`,
|
||||
requestParams: (params.removeNativeQuoteParam ?? removeTelegramNativeQuoteParam)(
|
||||
params.requestParams,
|
||||
),
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
@@ -100,6 +103,8 @@ export async function sendTelegramText(
|
||||
replyQuoteEntities?: unknown[];
|
||||
thread?: TelegramThreadSpec | null;
|
||||
textMode?: "markdown" | "html";
|
||||
plainText?: string;
|
||||
richMessages?: boolean;
|
||||
linkPreview?: boolean;
|
||||
tableMode?: MarkdownTableMode;
|
||||
silent?: boolean;
|
||||
@@ -115,31 +120,88 @@ export async function sendTelegramText(
|
||||
thread: opts?.thread,
|
||||
silent: opts?.silent,
|
||||
});
|
||||
const richParams = toTelegramRichMessageContextParams(baseParams);
|
||||
const textMode = opts?.textMode ?? "markdown";
|
||||
const richMessage = buildTelegramRichMessage(text, textMode, {
|
||||
skipEntityDetection: opts?.linkPreview === false,
|
||||
tableMode: opts?.tableMode,
|
||||
});
|
||||
const richRawApi = getTelegramRichRawApi(bot.api);
|
||||
|
||||
if (!text.trim()) {
|
||||
throw new Error("Message must be non-empty for Telegram sends");
|
||||
if (opts?.richMessages === true) {
|
||||
const richMessage = buildTelegramRichMessage(text, textMode, {
|
||||
skipEntityDetection: opts.linkPreview === false,
|
||||
tableMode: opts.tableMode,
|
||||
});
|
||||
const res = await sendTelegramWithThreadFallback({
|
||||
operation: "sendRichMessage",
|
||||
runtime,
|
||||
thread: opts.thread,
|
||||
requestParams: toTelegramRichMessageContextParams(baseParams),
|
||||
removeNativeQuoteParam: removeTelegramRichNativeQuoteParam,
|
||||
send: (effectiveParams) =>
|
||||
getTelegramRichRawApi(bot.api).sendRichMessage({
|
||||
chat_id: chatId,
|
||||
rich_message: richMessage,
|
||||
...(opts.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
||||
...effectiveParams,
|
||||
}),
|
||||
});
|
||||
runtime.log?.(`telegram sendRichMessage ok chat=${chatId} message=${res.message_id}`);
|
||||
return res.message_id;
|
||||
}
|
||||
// Add link_preview_options when link preview is disabled.
|
||||
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 hasFallbackText = fallbackText.trim().length > 0;
|
||||
const sendPlainFallback = async () => {
|
||||
const res = await sendTelegramWithThreadFallback({
|
||||
operation: "sendMessage",
|
||||
runtime,
|
||||
thread: opts?.thread,
|
||||
requestParams: baseParams,
|
||||
send: (effectiveParams) =>
|
||||
bot.api.sendMessage(chatId, fallbackText, {
|
||||
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
||||
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
||||
...effectiveParams,
|
||||
}),
|
||||
});
|
||||
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id} (plain)`);
|
||||
return res.message_id;
|
||||
};
|
||||
|
||||
// Markdown can render to empty HTML for syntax-only chunks; recover with plain text.
|
||||
if (!htmlText.trim()) {
|
||||
if (!hasFallbackText) {
|
||||
throw new Error("telegram sendMessage failed: empty formatted text and empty plain fallback");
|
||||
}
|
||||
return await sendPlainFallback();
|
||||
}
|
||||
try {
|
||||
const res = await sendTelegramWithThreadFallback({
|
||||
operation: "sendMessage",
|
||||
runtime,
|
||||
thread: opts?.thread,
|
||||
requestParams: baseParams,
|
||||
shouldLog: (err) => {
|
||||
const errText = formatErrorMessage(err);
|
||||
return !PARSE_ERR_RE.test(errText) && !EMPTY_TEXT_ERR_RE.test(errText);
|
||||
},
|
||||
send: (effectiveParams) =>
|
||||
bot.api.sendMessage(chatId, htmlText, {
|
||||
parse_mode: "HTML",
|
||||
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
||||
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
||||
...effectiveParams,
|
||||
}),
|
||||
});
|
||||
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id}`);
|
||||
return res.message_id;
|
||||
} catch (err) {
|
||||
const errText = formatErrorMessage(err);
|
||||
if (PARSE_ERR_RE.test(errText) || EMPTY_TEXT_ERR_RE.test(errText)) {
|
||||
if (!hasFallbackText) {
|
||||
throw err;
|
||||
}
|
||||
runtime.log?.(`telegram formatted send failed; retrying without formatting: ${errText}`);
|
||||
return await sendPlainFallback();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const res = await sendTelegramWithThreadFallback({
|
||||
operation: "sendRichMessage",
|
||||
runtime,
|
||||
thread: opts?.thread,
|
||||
requestParams: richParams,
|
||||
removeNativeQuoteParam: removeTelegramRichNativeQuoteParam,
|
||||
send: (effectiveParams) =>
|
||||
richRawApi.sendRichMessage({
|
||||
chat_id: chatId,
|
||||
rich_message: richMessage,
|
||||
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
||||
...effectiveParams,
|
||||
}),
|
||||
});
|
||||
runtime.log?.(`telegram sendRichMessage ok chat=${chatId} message=${res.message_id}`);
|
||||
return res.message_id;
|
||||
}
|
||||
|
||||
@@ -813,7 +813,7 @@ describe("deliverReplies", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("skips rich entity detection when link previews are disabled", async () => {
|
||||
it("disables link previews without rich-only entity flags", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 3,
|
||||
@@ -830,7 +830,10 @@ describe("deliverReplies", () => {
|
||||
|
||||
expect(firstMockCallArg(sendMessage, 0)).toBe("123");
|
||||
firstSendText(sendMessage);
|
||||
expectRecordFields(mockCallArg(sendMessage, 0, 2), { skip_entity_detection: true });
|
||||
expectRecordFields(mockCallArg(sendMessage, 0, 2), {
|
||||
link_preview_options: { is_disabled: true },
|
||||
});
|
||||
expect(mockCallArg(sendMessage, 0, 2)).not.toHaveProperty("skip_entity_detection");
|
||||
});
|
||||
|
||||
it("includes message_thread_id for DM topics", async () => {
|
||||
@@ -1097,6 +1100,48 @@ describe("deliverReplies", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("retries rich messages without converting reply parameters to legacy fields", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(createQuoteNotFoundError())
|
||||
.mockResolvedValueOnce({
|
||||
message_id: 11,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "Hello there", replyToId: "500" }],
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "all",
|
||||
replyQuoteMessageId: 500,
|
||||
replyQuoteText: " quoted text\n",
|
||||
richMessages: true,
|
||||
});
|
||||
|
||||
const raw = bot.api.raw as unknown as {
|
||||
sendRichMessage: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const { sendRichMessage } = raw;
|
||||
expect(sendRichMessage).toHaveBeenCalledTimes(2);
|
||||
expectRecordFields(firstMockCallArg(sendRichMessage, 0), {
|
||||
reply_parameters: {
|
||||
message_id: 500,
|
||||
quote: " quoted text\n",
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
});
|
||||
expectRecordFields(mockCallArg(sendRichMessage, 1, 0), {
|
||||
reply_parameters: {
|
||||
message_id: 500,
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
});
|
||||
expect(mockCallArg(sendRichMessage, 1, 0)).not.toHaveProperty("reply_to_message_id");
|
||||
});
|
||||
|
||||
it("uses legacy reply id when selected reply target differs from quote source", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -23,12 +23,78 @@ describe("telegram actions contract", () => {
|
||||
],
|
||||
});
|
||||
|
||||
it("advertises Telegram rich text to the agent prompt", () => {
|
||||
it.each([
|
||||
{ richMessages: undefined, expected: false },
|
||||
{ richMessages: false, expected: false },
|
||||
{ richMessages: true, expected: true },
|
||||
])("advertises Telegram rich text only when enabled", ({ richMessages, expected }) => {
|
||||
const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "123:telegram-test-token",
|
||||
richMessages,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(capabilities).toContain("inlineButtons");
|
||||
expect(capabilities?.includes("richText")).toBe(expected);
|
||||
});
|
||||
|
||||
it("uses the selected Telegram account's rich text setting", () => {
|
||||
const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "123:telegram-test-token",
|
||||
richMessages: true,
|
||||
accounts: {
|
||||
ops: {
|
||||
richMessages: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountId: "ops",
|
||||
});
|
||||
|
||||
expect(capabilities).not.toContain("richText");
|
||||
});
|
||||
|
||||
it("does not resolve Telegram credentials while checking prompt capabilities", () => {
|
||||
expect(() =>
|
||||
telegramPlugin.agentPrompt?.messageToolCapabilities?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
tokenFile: "/definitely/missing/telegram-token",
|
||||
richMessages: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("uses the configured default Telegram account for prompt capabilities", () => {
|
||||
const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultAccount: "ops",
|
||||
accounts: {
|
||||
default: {
|
||||
botToken: "123:default-token",
|
||||
richMessages: false,
|
||||
},
|
||||
ops: {
|
||||
botToken: "123:ops-token",
|
||||
richMessages: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
|
||||
@@ -36,7 +36,12 @@ import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveTelegramAccount, type ResolvedTelegramAccount } from "./accounts.js";
|
||||
import {
|
||||
mergeTelegramAccountConfig,
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccount,
|
||||
type ResolvedTelegramAccount,
|
||||
} from "./accounts.js";
|
||||
import { resolveTelegramAutoThreadId } from "./action-threading.js";
|
||||
import { lookupTelegramChatId } from "./api-fetch.js";
|
||||
import { telegramApprovalCapability } from "./approval-native.js";
|
||||
@@ -783,7 +788,12 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
return inlineButtonsScope === "off" ? ["richText"] : ["inlineButtons", "richText"];
|
||||
const capabilities = inlineButtonsScope === "off" ? [] : ["inlineButtons"];
|
||||
const selectedAccountId = accountId ?? resolveDefaultTelegramAccountId(cfg);
|
||||
if (mergeTelegramAccountConfig(cfg, selectedAccountId).richMessages === true) {
|
||||
capabilities.push("richText");
|
||||
}
|
||||
return capabilities;
|
||||
},
|
||||
reactionGuidance: ({ cfg, accountId }) => {
|
||||
const level = resolveTelegramReactionLevel({
|
||||
|
||||
@@ -153,6 +153,19 @@ describe("telegram custom commands schema", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts rich message opt-in per account", () => {
|
||||
const res = TelegramConfigSchema.safeParse({
|
||||
richMessages: true,
|
||||
accounts: { ops: { richMessages: false } },
|
||||
});
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
if (res.success) {
|
||||
expect(res.data.richMessages).toBe(true);
|
||||
expect(res.data.accounts?.ops?.richMessages).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes custom commands", () => {
|
||||
const res = TelegramConfigSchema.safeParse({
|
||||
customCommands: [{ command: "/Backup", description: " Git backup " }],
|
||||
|
||||
@@ -62,6 +62,10 @@ export const telegramChannelConfigUiHints = {
|
||||
label: "Telegram Chunk Mode",
|
||||
help: 'Chunking mode for outbound Telegram text delivery: "length" (default) or "newline".',
|
||||
},
|
||||
richMessages: {
|
||||
label: "Telegram Rich Messages",
|
||||
help: "Opt into Bot API 10.1 rich text sends and edits, including native tables and rich media. Default: false because some current Telegram clients render these messages as unsupported.",
|
||||
},
|
||||
"streaming.block.enabled": {
|
||||
label: "Telegram Block Streaming Enabled",
|
||||
help: 'Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode="block".',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Bot } from "grammy";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||
import { markdownToTelegramRichHtml } from "./format.js";
|
||||
import type { TelegramInputRichMessage } from "./rich-message.js";
|
||||
|
||||
type TelegramDraftStreamParams = Parameters<typeof createTelegramDraftStream>[0];
|
||||
|
||||
@@ -47,50 +47,56 @@ async function expectInitialForumSend(
|
||||
text = "Hello",
|
||||
): Promise<void> {
|
||||
await vi.waitFor(() =>
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
rich_message: { html: markdownToTelegramRichHtml(text) },
|
||||
expect(api.sendMessage).toHaveBeenCalledWith(123, text, {
|
||||
message_thread_id: 99,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function expectRichSend(
|
||||
function expectPreviewSend(
|
||||
api: ReturnType<typeof createMockDraftApi>,
|
||||
text: string,
|
||||
params: Record<string, unknown> = {},
|
||||
) {
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
rich_message: { html: markdownToTelegramRichHtml(text) },
|
||||
...params,
|
||||
});
|
||||
expect(api.sendMessage).toHaveBeenCalledWith(123, text, params);
|
||||
}
|
||||
|
||||
function expectNthRichSend(
|
||||
function expectNthPreviewSend(
|
||||
api: ReturnType<typeof createMockDraftApi>,
|
||||
call: number,
|
||||
text: string,
|
||||
params: Record<string, unknown> = {},
|
||||
) {
|
||||
expect(api.raw.sendRichMessage).toHaveBeenNthCalledWith(call, {
|
||||
chat_id: 123,
|
||||
rich_message: { html: markdownToTelegramRichHtml(text) },
|
||||
...params,
|
||||
});
|
||||
expect(api.sendMessage).toHaveBeenNthCalledWith(call, 123, text, params);
|
||||
}
|
||||
|
||||
function expectRichEdit(api: ReturnType<typeof createMockDraftApi>, text: string) {
|
||||
expect(api.raw.editMessageText).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
message_id: 17,
|
||||
rich_message: { html: markdownToTelegramRichHtml(text) },
|
||||
});
|
||||
function requireSendMessageCallText(
|
||||
api: ReturnType<typeof createMockDraftApi>,
|
||||
callIndex: number,
|
||||
): string {
|
||||
const calls = api.sendMessage.mock.calls as unknown[][];
|
||||
const call = calls[callIndex];
|
||||
expect(call, `sendMessage call ${callIndex}`).toBeDefined();
|
||||
const text = call?.[1];
|
||||
expect(typeof text).toBe("string");
|
||||
return typeof text === "string" ? text : "";
|
||||
}
|
||||
|
||||
function expectPreviewEdit(
|
||||
api: ReturnType<typeof createMockDraftApi>,
|
||||
text: string,
|
||||
params?: Record<string, unknown>,
|
||||
) {
|
||||
if (params) {
|
||||
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, text, params);
|
||||
return;
|
||||
}
|
||||
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, text);
|
||||
}
|
||||
|
||||
function createForceNewMessageHarness(params: { throttleMs?: number } = {}) {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage
|
||||
api.sendMessage
|
||||
.mockResolvedValueOnce({ message_id: 17 })
|
||||
.mockResolvedValueOnce({ message_id: 42 });
|
||||
const stream = createDraftStream(
|
||||
@@ -115,12 +121,12 @@ describe("createTelegramDraftStream", () => {
|
||||
|
||||
stream.update("Hello");
|
||||
await expectInitialForumSend(api);
|
||||
await (api.raw.sendRichMessage.mock.results[0]?.value as Promise<unknown>);
|
||||
await (api.sendMessage.mock.results[0]?.value as Promise<unknown>);
|
||||
|
||||
stream.update("Hello again");
|
||||
await stream.flush();
|
||||
|
||||
expectRichEdit(api, "Hello again");
|
||||
expectPreviewEdit(api, "Hello again");
|
||||
});
|
||||
|
||||
it("waits for in-flight updates before final flush edit", async () => {
|
||||
@@ -132,15 +138,15 @@ describe("createTelegramDraftStream", () => {
|
||||
const stream = createForumDraftStream(api);
|
||||
|
||||
stream.update("Hello");
|
||||
await vi.waitFor(() => expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1));
|
||||
stream.update("Hello final");
|
||||
const flushPromise = stream.flush();
|
||||
expect(api.raw.editMessageText).not.toHaveBeenCalled();
|
||||
expect(api.editMessageText).not.toHaveBeenCalled();
|
||||
|
||||
resolveSend?.({ message_id: 17 });
|
||||
await flushPromise;
|
||||
|
||||
expectRichEdit(api, "Hello final");
|
||||
expectPreviewEdit(api, "Hello final");
|
||||
});
|
||||
|
||||
it("omits message_thread_id for general topic id", async () => {
|
||||
@@ -149,21 +155,21 @@ describe("createTelegramDraftStream", () => {
|
||||
|
||||
stream.update("Hello");
|
||||
|
||||
await vi.waitFor(() => expectRichSend(api, "Hello"));
|
||||
await vi.waitFor(() => expectPreviewSend(api, "Hello"));
|
||||
});
|
||||
|
||||
it("uses rich send/edit for dm thread previews", async () => {
|
||||
it("uses text send/edit for dm thread previews", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const stream = createThreadedDraftStream(api, { id: 42, scope: "dm" });
|
||||
|
||||
stream.update("Hello");
|
||||
await vi.waitFor(() => expectRichSend(api, "Hello", { message_thread_id: 42 }));
|
||||
expect(api.raw.editMessageText).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => expectPreviewSend(api, "Hello", { message_thread_id: 42 }));
|
||||
expect(api.editMessageText).not.toHaveBeenCalled();
|
||||
|
||||
stream.update("Hello again");
|
||||
await stream.flush();
|
||||
|
||||
expectRichEdit(api, "Hello again");
|
||||
expectPreviewEdit(api, "Hello again");
|
||||
});
|
||||
|
||||
it("tracks when a message preview first became visible", async () => {
|
||||
@@ -192,7 +198,7 @@ describe("createTelegramDraftStream", () => {
|
||||
"does not retry %s message preview sends without the topic id",
|
||||
async (scope) => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage.mockRejectedValueOnce(
|
||||
api.sendMessage.mockRejectedValueOnce(
|
||||
new Error("400: Bad Request: message thread not found"),
|
||||
);
|
||||
const warn = vi.fn();
|
||||
@@ -204,8 +210,8 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("Hello");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expectRichSend(api, "Hello", { message_thread_id: 42 });
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expectPreviewSend(api, "Hello", { message_thread_id: 42 });
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"telegram stream preview failed: 400: Bad Request: message thread not found",
|
||||
);
|
||||
@@ -217,7 +223,7 @@ describe("createTelegramDraftStream", () => {
|
||||
|
||||
it("does not finalize stale preview text after a stopped send failure", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage.mockRejectedValueOnce(new Error("temporary send failure"));
|
||||
api.sendMessage.mockRejectedValueOnce(new Error("temporary send failure"));
|
||||
const warn = vi.fn();
|
||||
const stream = createDraftStream(api, { warn });
|
||||
|
||||
@@ -225,8 +231,8 @@ describe("createTelegramDraftStream", () => {
|
||||
await stream.flush();
|
||||
await stream.stop();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expectRichSend(api, "Hello");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expectPreviewSend(api, "Hello");
|
||||
expect(warn).toHaveBeenCalledWith("telegram stream preview failed: temporary send failure");
|
||||
});
|
||||
|
||||
@@ -240,7 +246,7 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("Hello");
|
||||
await stream.flush();
|
||||
|
||||
expectRichSend(api, "Hello", {
|
||||
expectPreviewSend(api, "Hello", {
|
||||
message_thread_id: 42,
|
||||
reply_parameters: {
|
||||
message_id: 411,
|
||||
@@ -249,13 +255,13 @@ describe("createTelegramDraftStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("materializes message previews using rendered rich HTML", async () => {
|
||||
it("materializes message previews using rendered HTML text", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const stream = createDraftStream(api, {
|
||||
thread: { id: 42, scope: "dm" },
|
||||
renderText: (text) => ({
|
||||
text: text.replace("**bold**", "<b>bold</b>"),
|
||||
richMessage: { html: text.replace("**bold**", "<b>bold</b>") },
|
||||
parseMode: "HTML",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -264,12 +270,11 @@ describe("createTelegramDraftStream", () => {
|
||||
const materializedId = await stream.materialize?.();
|
||||
|
||||
expect(materializedId).toBe(17);
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
rich_message: { html: "<b>bold</b>" },
|
||||
expect(api.sendMessage).toHaveBeenCalledWith(123, "<b>bold</b>", {
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 42,
|
||||
});
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns existing preview id when materializing message transport", async () => {
|
||||
@@ -283,7 +288,8 @@ describe("createTelegramDraftStream", () => {
|
||||
const materializedId = await stream.materialize?.();
|
||||
|
||||
expect(materializedId).toBe(17);
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes message preview on clear after finalization", async () => {
|
||||
@@ -296,8 +302,8 @@ describe("createTelegramDraftStream", () => {
|
||||
await stream.stop();
|
||||
await stream.clear();
|
||||
|
||||
expectRichSend(api, "Hello", { message_thread_id: 42 });
|
||||
expectRichEdit(api, "Hello again");
|
||||
expectPreviewSend(api, "Hello", { message_thread_id: 42 });
|
||||
expectPreviewEdit(api, "Hello again");
|
||||
expect(api.deleteMessage).toHaveBeenCalledWith(123, 17);
|
||||
});
|
||||
|
||||
@@ -307,12 +313,12 @@ describe("createTelegramDraftStream", () => {
|
||||
// First message
|
||||
stream.update("Hello");
|
||||
await stream.flush();
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Normal edit (same message)
|
||||
stream.update("Hello edited");
|
||||
await stream.flush();
|
||||
expectRichEdit(api, "Hello edited");
|
||||
expectPreviewEdit(api, "Hello edited");
|
||||
|
||||
// Force new message (e.g. after thinking block ends)
|
||||
stream.forceNewMessage();
|
||||
@@ -320,8 +326,8 @@ describe("createTelegramDraftStream", () => {
|
||||
await stream.flush();
|
||||
|
||||
// Should have sent a second new message, not edited the first
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthRichSend(api, 2, "After thinking");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthPreviewSend(api, 2, "After thinking");
|
||||
});
|
||||
|
||||
it("creates new message after cleanup and forceNewMessage", async () => {
|
||||
@@ -337,8 +343,8 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("Next preview");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthRichSend(api, 2, "Next preview");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthPreviewSend(api, 2, "Next preview");
|
||||
});
|
||||
|
||||
it("sends first update immediately after forceNewMessage within throttle window", async () => {
|
||||
@@ -347,15 +353,15 @@ describe("createTelegramDraftStream", () => {
|
||||
const { api, stream } = createForceNewMessageHarness({ throttleMs: 1000 });
|
||||
|
||||
stream.update("Hello");
|
||||
await vi.waitFor(() => expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1));
|
||||
|
||||
stream.update("Hello edited");
|
||||
expect(api.raw.editMessageText).not.toHaveBeenCalled();
|
||||
expect(api.editMessageText).not.toHaveBeenCalled();
|
||||
|
||||
stream.forceNewMessage();
|
||||
stream.update("Second message");
|
||||
await vi.waitFor(() => expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2));
|
||||
expectNthRichSend(api, 2, "Second message");
|
||||
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(2));
|
||||
expectNthPreviewSend(api, 2, "Second message");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -367,14 +373,12 @@ describe("createTelegramDraftStream", () => {
|
||||
resolveFirstSend = resolve;
|
||||
});
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage
|
||||
.mockReturnValueOnce(firstSend)
|
||||
.mockResolvedValueOnce({ message_id: 42 });
|
||||
api.sendMessage.mockReturnValueOnce(firstSend).mockResolvedValueOnce({ message_id: 42 });
|
||||
const onSupersededPreview = vi.fn();
|
||||
const stream = createDraftStream(api, { onSupersededPreview });
|
||||
|
||||
stream.update("Message A partial");
|
||||
await vi.waitFor(() => expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1));
|
||||
|
||||
stream.forceNewMessage();
|
||||
stream.update("Message B partial");
|
||||
@@ -392,44 +396,38 @@ describe("createTelegramDraftStream", () => {
|
||||
});
|
||||
expect(typeof supersededPreview.visibleSinceMs).toBe("number");
|
||||
expect(Number.isFinite(supersededPreview.visibleSinceMs)).toBe(true);
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthRichSend(api, 2, "Message B partial");
|
||||
expect(api.raw.editMessageText).not.toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
message_id: 17,
|
||||
rich_message: { html: markdownToTelegramRichHtml("Message B partial") },
|
||||
});
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthPreviewSend(api, 2, "Message B partial");
|
||||
expect(api.editMessageText).not.toHaveBeenCalledWith(123, 17, "Message B partial");
|
||||
});
|
||||
|
||||
it("marks sendMayHaveLanded after an ambiguous first preview send failure", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage.mockRejectedValueOnce(
|
||||
new Error("timeout after Telegram accepted send"),
|
||||
);
|
||||
api.sendMessage.mockRejectedValueOnce(new Error("timeout after Telegram accepted send"));
|
||||
const stream = createDraftStream(api);
|
||||
|
||||
stream.update("Hello");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(stream.sendMayHaveLanded?.()).toBe(true);
|
||||
});
|
||||
|
||||
async function expectSendMayHaveLandedStateAfterFirstFailure(error: Error, expected: boolean) {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage.mockRejectedValueOnce(error);
|
||||
api.sendMessage.mockRejectedValueOnce(error);
|
||||
const stream = createDraftStream(api);
|
||||
|
||||
stream.update("Hello");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(stream.sendMayHaveLanded?.()).toBe(expected);
|
||||
}
|
||||
|
||||
it("retries pre-connect first preview send failures instead of stopping", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage.mockRejectedValueOnce(
|
||||
api.sendMessage.mockRejectedValueOnce(
|
||||
Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }),
|
||||
);
|
||||
const stream = createDraftStream(api);
|
||||
@@ -438,7 +436,7 @@ describe("createTelegramDraftStream", () => {
|
||||
await stream.flush();
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(stream.sendMayHaveLanded?.()).toBe(false);
|
||||
expect(stream.messageId()).toBe(17);
|
||||
});
|
||||
@@ -452,7 +450,7 @@ describe("createTelegramDraftStream", () => {
|
||||
|
||||
it("treats message-is-not-modified edits as delivered", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.editMessageText.mockRejectedValueOnce(
|
||||
api.editMessageText.mockRejectedValueOnce(
|
||||
Object.assign(
|
||||
new Error("Call to 'editMessageText' failed! (400: Bad Request: message is not modified)"),
|
||||
{ error_code: 400 },
|
||||
@@ -468,18 +466,14 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("Hello more");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.editMessageText).toHaveBeenCalledTimes(2);
|
||||
expect(api.raw.editMessageText).toHaveBeenLastCalledWith({
|
||||
chat_id: 123,
|
||||
message_id: 17,
|
||||
rich_message: { html: markdownToTelegramRichHtml("Hello more") },
|
||||
});
|
||||
expect(api.editMessageText).toHaveBeenCalledTimes(2);
|
||||
expect(api.editMessageText).toHaveBeenLastCalledWith(123, 17, "Hello more");
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries the preview edit after a transient network failure", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.editMessageText.mockRejectedValueOnce(
|
||||
api.editMessageText.mockRejectedValueOnce(
|
||||
Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }),
|
||||
);
|
||||
const warn = vi.fn();
|
||||
@@ -495,12 +489,8 @@ describe("createTelegramDraftStream", () => {
|
||||
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.editMessageText).toHaveBeenCalledTimes(2);
|
||||
expect(api.raw.editMessageText).toHaveBeenLastCalledWith({
|
||||
chat_id: 123,
|
||||
message_id: 17,
|
||||
rich_message: { html: markdownToTelegramRichHtml("Hello again") },
|
||||
});
|
||||
expect(api.editMessageText).toHaveBeenCalledTimes(2);
|
||||
expect(api.editMessageText).toHaveBeenLastCalledWith(123, 17, "Hello again");
|
||||
expect(stream.lastDeliveredText?.()).toBe("Hello again");
|
||||
});
|
||||
|
||||
@@ -508,7 +498,7 @@ describe("createTelegramDraftStream", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.editMessageText.mockRejectedValueOnce(
|
||||
api.editMessageText.mockRejectedValueOnce(
|
||||
Object.assign(
|
||||
new Error("Call to 'editMessageText' failed! (429: Too Many Requests: retry after 1)"),
|
||||
{ error_code: 429, parameters: { retry_after: 1 } },
|
||||
@@ -522,17 +512,13 @@ describe("createTelegramDraftStream", () => {
|
||||
await stream.flush();
|
||||
stream.update("Hello more");
|
||||
await stream.flush();
|
||||
expect(api.raw.editMessageText).toHaveBeenCalledTimes(1);
|
||||
expect(api.editMessageText).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.editMessageText).toHaveBeenCalledTimes(2);
|
||||
expect(api.raw.editMessageText).toHaveBeenLastCalledWith({
|
||||
chat_id: 123,
|
||||
message_id: 17,
|
||||
rich_message: { html: markdownToTelegramRichHtml("Hello more") },
|
||||
});
|
||||
expect(api.editMessageText).toHaveBeenCalledTimes(2);
|
||||
expect(api.editMessageText).toHaveBeenLastCalledWith(123, 17, "Hello more");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -540,7 +526,7 @@ describe("createTelegramDraftStream", () => {
|
||||
|
||||
it("stops the preview after repeated retryable edit failures", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.editMessageText.mockRejectedValue(
|
||||
api.editMessageText.mockRejectedValue(
|
||||
Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }),
|
||||
);
|
||||
const warn = vi.fn();
|
||||
@@ -555,35 +541,32 @@ describe("createTelegramDraftStream", () => {
|
||||
await stream.flush();
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.editMessageText).toHaveBeenCalledTimes(4);
|
||||
expect(api.editMessageText).toHaveBeenCalledTimes(4);
|
||||
expect(warn).toHaveBeenCalledWith("telegram stream preview failed: read ECONNRESET");
|
||||
});
|
||||
|
||||
it("supports rendered previews with rich HTML", async () => {
|
||||
it("supports rendered previews with HTML parse mode", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const stream = createTelegramDraftStream({
|
||||
api: api as unknown as Bot["api"],
|
||||
chatId: 123,
|
||||
renderText: (text) => ({ text: `<i>${text}</i>`, richMessage: { html: `<i>${text}</i>` } }),
|
||||
renderText: (text) => ({ text: `<i>${text}</i>`, parseMode: "HTML" }),
|
||||
});
|
||||
|
||||
stream.update("hello");
|
||||
await stream.flush();
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
rich_message: { html: "<i>hello</i>" },
|
||||
expect(api.sendMessage).toHaveBeenCalledWith(123, "<i>hello</i>", {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
|
||||
stream.update("hello again");
|
||||
await stream.flush();
|
||||
expect(api.raw.editMessageText).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
message_id: 17,
|
||||
rich_message: { html: "<i>hello again</i>" },
|
||||
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "<i>hello again</i>", {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses caller-provided rich previews", async () => {
|
||||
it("sends caller-provided rich previews through standard text transport", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const stream = createDraftStream(api);
|
||||
|
||||
@@ -596,13 +579,10 @@ describe("createTelegramDraftStream", () => {
|
||||
});
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
rich_message: {
|
||||
html: "<b>Shelling</b><br><b>🛠️ Exec</b>",
|
||||
skip_entity_detection: true,
|
||||
},
|
||||
expect(api.sendMessage).toHaveBeenCalledWith(123, "<b>Shelling</b><br><b>🛠️ Exec</b>", {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
|
||||
|
||||
stream.updatePreview({
|
||||
text: "Shelling\n\n`🛠️ Exec`\n• _Checking files_",
|
||||
@@ -613,43 +593,76 @@ describe("createTelegramDraftStream", () => {
|
||||
});
|
||||
await stream.flush();
|
||||
|
||||
expect(api.editMessageText).toHaveBeenCalledWith(
|
||||
123,
|
||||
17,
|
||||
"<b>Shelling</b><br><b>🛠️ Exec</b><br><i>Checking files</i>",
|
||||
{ parse_mode: "HTML" },
|
||||
);
|
||||
expect(api.raw.editMessageText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses rich send and edit for previews when explicitly enabled", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const stream = createDraftStream(api, { richMessages: true });
|
||||
|
||||
stream.updatePreview({
|
||||
text: "Plan",
|
||||
richMessage: { html: "<h2>Plan</h2><table><tr><td>A</td></tr></table>" },
|
||||
});
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
rich_message: { html: "<h2>Plan</h2><table><tr><td>A</td></tr></table>" },
|
||||
});
|
||||
expect(api.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
stream.updatePreview({
|
||||
text: "Plan updated",
|
||||
richMessage: { html: "<h2>Plan updated</h2><table><tr><td>B</td></tr></table>" },
|
||||
});
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.editMessageText).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
message_id: 17,
|
||||
rich_message: {
|
||||
html: "<b>Shelling</b><br><b>🛠️ Exec</b><br><i>Checking files</i>",
|
||||
skip_entity_detection: true,
|
||||
},
|
||||
rich_message: { html: "<h2>Plan updated</h2><table><tr><td>B</td></tr></table>" },
|
||||
});
|
||||
expect(api.editMessageText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps rich rendered previews above the old text-message limit", async () => {
|
||||
const richApi = {
|
||||
sendRichMessage: vi.fn(async () => ({ message_id: 17 })),
|
||||
editMessageText: vi.fn(async () => true),
|
||||
};
|
||||
const api = {
|
||||
...createMockDraftApi(),
|
||||
raw: richApi,
|
||||
};
|
||||
it("clamps rich previews to the block limit", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const text = Array.from({ length: 501 }, (_, index) => `paragraph ${index}`).join("\n\n");
|
||||
const stream = createDraftStream(api, { richMessages: true });
|
||||
|
||||
stream.update(text);
|
||||
await stream.flush();
|
||||
|
||||
const calls = api.raw.sendRichMessage.mock.calls as unknown[][];
|
||||
const params = calls[0]?.[0] as { rich_message?: TelegramInputRichMessage } | undefined;
|
||||
const richMessage = params?.rich_message;
|
||||
expect(richMessage?.html).toContain("paragraph 499");
|
||||
expect(richMessage?.html).not.toContain("paragraph 500");
|
||||
});
|
||||
|
||||
it("clamps rendered previews to the text-message limit", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const text = `# Long\n\n${"rich line\n".repeat(600)}`;
|
||||
const stream = createTelegramDraftStream({
|
||||
api: api as unknown as Bot["api"],
|
||||
chatId: 123,
|
||||
renderText: (value) => ({
|
||||
text: value,
|
||||
richMessage: { html: markdownToTelegramRichHtml(value) },
|
||||
}),
|
||||
renderText: (value) => ({ text: value }),
|
||||
});
|
||||
|
||||
stream.update(text);
|
||||
await stream.flush();
|
||||
|
||||
expect(richApi.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
rich_message: { html: markdownToTelegramRichHtml(text.trimEnd()) },
|
||||
});
|
||||
expect(api.sendMessage).not.toHaveBeenCalled();
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
const sentText = requireSendMessageCallText(api, 0);
|
||||
expect(sentText.length).toBeLessThanOrEqual(4000);
|
||||
expect(sentText.startsWith("# Long\n\nrich line")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps non-final overflow in one editable preview", async () => {
|
||||
@@ -662,9 +675,9 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("Hello world foo bar baz qux");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expectNthRichSend(api, 1, "Hello world");
|
||||
expectRichEdit(api, "Hello world foo bar");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expectNthPreviewSend(api, 1, "Hello world");
|
||||
expectPreviewEdit(api, "Hello world foo bar");
|
||||
expect(onSupersededPreview).not.toHaveBeenCalled();
|
||||
expect(stream.lastDeliveredText?.()).toBe("Hello world foo bar");
|
||||
});
|
||||
@@ -682,14 +695,14 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("Hello world foo bar baz qux");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expectRichEdit(api, "Hello world foo bar");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expectPreviewEdit(api, "Hello world foo bar");
|
||||
expect(onSupersededPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues in a new message when a final rendered preview crosses maxChars", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage
|
||||
api.sendMessage
|
||||
.mockResolvedValueOnce({ message_id: 17 })
|
||||
.mockResolvedValueOnce({ message_id: 42 });
|
||||
const stream = createDraftStream(api, { maxChars: 20 });
|
||||
@@ -699,9 +712,9 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("Hello world foo bar baz qux");
|
||||
await stream.stop();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthRichSend(api, 1, "Hello world");
|
||||
expectNthRichSend(api, 2, "foo bar baz qux");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthPreviewSend(api, 1, "Hello world");
|
||||
expectNthPreviewSend(api, 2, "foo bar baz qux");
|
||||
});
|
||||
|
||||
it("clamps a first oversized non-final preview", async () => {
|
||||
@@ -711,14 +724,14 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("1234567890ABCDEFGHIJ");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expectNthRichSend(api, 1, "1234567890");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expectNthPreviewSend(api, 1, "1234567890");
|
||||
expect(stream.lastDeliveredText?.()).toBe("1234567890");
|
||||
});
|
||||
|
||||
it("finalizes overflow that was hidden by a clamped non-final preview", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage
|
||||
api.sendMessage
|
||||
.mockResolvedValueOnce({ message_id: 17 })
|
||||
.mockResolvedValueOnce({ message_id: 42 });
|
||||
const onSupersededPreview = vi.fn();
|
||||
@@ -731,9 +744,9 @@ describe("createTelegramDraftStream", () => {
|
||||
await stream.flush();
|
||||
await stream.stop();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthRichSend(api, 1, "1234567890");
|
||||
expectNthRichSend(api, 2, "ABCDEFGHIJ");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthPreviewSend(api, 1, "1234567890");
|
||||
expectNthPreviewSend(api, 2, "ABCDEFGHIJ");
|
||||
expect(stream.lastDeliveredText?.()).toBe("1234567890ABCDEFGHIJ");
|
||||
expect(onSupersededPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -745,7 +758,7 @@ describe("createTelegramDraftStream", () => {
|
||||
|
||||
it("continues finalizing more than two overflow chunks after a clamped preview", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage
|
||||
api.sendMessage
|
||||
.mockResolvedValueOnce({ message_id: 17 })
|
||||
.mockResolvedValueOnce({ message_id: 42 })
|
||||
.mockResolvedValueOnce({ message_id: 43 });
|
||||
@@ -755,16 +768,16 @@ describe("createTelegramDraftStream", () => {
|
||||
await stream.flush();
|
||||
await stream.stop();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(3);
|
||||
expectNthRichSend(api, 1, "1234567890");
|
||||
expectNthRichSend(api, 2, "ABCDEFGHIJ");
|
||||
expectNthRichSend(api, 3, "KLMNOPQRST");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(3);
|
||||
expectNthPreviewSend(api, 1, "1234567890");
|
||||
expectNthPreviewSend(api, 2, "ABCDEFGHIJ");
|
||||
expectNthPreviewSend(api, 3, "KLMNOPQRST");
|
||||
expect(stream.lastDeliveredText?.()).toBe("1234567890ABCDEFGHIJKLMNOPQRST");
|
||||
});
|
||||
|
||||
it("retains final overflow preview pages", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage
|
||||
api.sendMessage
|
||||
.mockResolvedValueOnce({ message_id: 17 })
|
||||
.mockResolvedValueOnce({ message_id: 42 });
|
||||
const onSupersededPreview = vi.fn();
|
||||
@@ -798,8 +811,8 @@ describe("createTelegramDraftStream", () => {
|
||||
chatId: 123,
|
||||
maxChars: 100,
|
||||
renderText: () => ({
|
||||
text: "short raw text",
|
||||
richMessage: { html: `<b>${"<".repeat(120)}</b>` },
|
||||
text: `<b>${"<".repeat(120)}</b>`,
|
||||
parseMode: "HTML",
|
||||
}),
|
||||
warn,
|
||||
});
|
||||
@@ -807,8 +820,8 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("short raw text");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
|
||||
expect(api.raw.editMessageText).not.toHaveBeenCalled();
|
||||
expect(api.sendMessage).not.toHaveBeenCalled();
|
||||
expect(api.editMessageText).not.toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledWith("telegram stream preview stopped (text length 127 > 100)");
|
||||
});
|
||||
});
|
||||
@@ -841,7 +854,7 @@ describe("draft stream initial message debounce", () => {
|
||||
await stream.stop();
|
||||
await stream.flush();
|
||||
|
||||
expectRichSend(api, "Y");
|
||||
expectPreviewSend(api, "Y");
|
||||
});
|
||||
|
||||
it("sends immediately on stop() with short sentence", async () => {
|
||||
@@ -852,7 +865,7 @@ describe("draft stream initial message debounce", () => {
|
||||
await stream.stop();
|
||||
await stream.flush();
|
||||
|
||||
expectRichSend(api, "Ok.");
|
||||
expectPreviewSend(api, "Ok.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -864,7 +877,7 @@ describe("draft stream initial message debounce", () => {
|
||||
stream.update("Processing");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
|
||||
expect(api.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send a first message when discard() supersedes a short partial", async () => {
|
||||
@@ -875,8 +888,8 @@ describe("draft stream initial message debounce", () => {
|
||||
await stream.discard?.();
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
|
||||
expect(api.raw.editMessageText).not.toHaveBeenCalled();
|
||||
expect(api.sendMessage).not.toHaveBeenCalled();
|
||||
expect(api.editMessageText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends first message when reaching threshold", async () => {
|
||||
@@ -886,7 +899,7 @@ describe("draft stream initial message debounce", () => {
|
||||
stream.update("I am processing your request..");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalled();
|
||||
expect(api.sendMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("works with longer text above threshold", async () => {
|
||||
@@ -896,7 +909,7 @@ describe("draft stream initial message debounce", () => {
|
||||
stream.update("I am processing your request, please wait a moment");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalled();
|
||||
expect(api.sendMessage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -907,18 +920,18 @@ describe("draft stream initial message debounce", () => {
|
||||
|
||||
stream.update("I am processing your request..");
|
||||
await stream.flush();
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
stream.update("I am processing your request.. and summarizing");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.editMessageText).toHaveBeenCalled();
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.editMessageText).toHaveBeenCalled();
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default behavior without debounce params", () => {
|
||||
it("sends rich markdown immediately without minInitialChars set", async () => {
|
||||
it("sends plain preview text immediately without minInitialChars set", async () => {
|
||||
const api = createMockApi();
|
||||
const stream = createTelegramDraftStream({
|
||||
api: api as unknown as Bot["api"],
|
||||
@@ -928,7 +941,7 @@ describe("draft stream initial message debounce", () => {
|
||||
stream.update("Hi");
|
||||
await stream.flush();
|
||||
|
||||
expectRichSend(api, "Hi");
|
||||
expectPreviewSend(api, "Hi");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js";
|
||||
import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "./format.js";
|
||||
import {
|
||||
isRecoverableTelegramNetworkError,
|
||||
isSafeToRetrySendError,
|
||||
@@ -14,17 +15,20 @@ import {
|
||||
isTelegramRateLimitError,
|
||||
readTelegramRetryAfterMs,
|
||||
} from "./network-errors.js";
|
||||
import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js";
|
||||
import { normalizeTelegramReplyToMessageId } from "./outbound-params.js";
|
||||
import {
|
||||
buildTelegramRichMarkdown,
|
||||
TELEGRAM_RICH_TEXT_LIMIT,
|
||||
getTelegramRichRawApi,
|
||||
isTelegramRichMessageWithinStructuralLimits,
|
||||
TELEGRAM_RICH_TEXT_LIMIT,
|
||||
type TelegramInputRichMessage,
|
||||
type TelegramSendRichMessageParams,
|
||||
} from "./rich-message.js";
|
||||
|
||||
const TELEGRAM_STREAM_MAX_CHARS = TELEGRAM_RICH_TEXT_LIMIT;
|
||||
const TELEGRAM_STREAM_MAX_CHARS = TELEGRAM_TEXT_CHUNK_LIMIT;
|
||||
const DEFAULT_THROTTLE_MS = 1000;
|
||||
const TELEGRAM_PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
||||
// Retryable preview failures keep the latest text pending for the next throttle
|
||||
// tick; cap consecutive misses so a persistent outage stops the preview instead
|
||||
// of warn-spamming for the rest of the run.
|
||||
@@ -55,7 +59,8 @@ export type TelegramDraftStream = {
|
||||
|
||||
export type TelegramDraftPreview = {
|
||||
text: string;
|
||||
richMessage: TelegramInputRichMessage;
|
||||
parseMode?: "HTML";
|
||||
richMessage?: TelegramInputRichMessage;
|
||||
};
|
||||
|
||||
type SupersededTelegramPreview = {
|
||||
@@ -65,29 +70,76 @@ type SupersededTelegramPreview = {
|
||||
retain?: boolean;
|
||||
};
|
||||
|
||||
type TelegramDraftTransportPreview = {
|
||||
plainText: string;
|
||||
text: string;
|
||||
parseMode?: "HTML";
|
||||
};
|
||||
|
||||
function renderTelegramDraftPreview(
|
||||
text: string,
|
||||
renderText: ((text: string) => TelegramDraftPreview) | undefined,
|
||||
): TelegramDraftPreview {
|
||||
const trimmed = text.trimEnd();
|
||||
return (
|
||||
renderText?.(trimmed) ?? { text: trimmed, richMessage: buildTelegramRichMarkdown(trimmed) }
|
||||
);
|
||||
return renderText?.(trimmed) ?? { text: trimmed };
|
||||
}
|
||||
|
||||
function isTelegramHtmlParseError(err: unknown): boolean {
|
||||
return TELEGRAM_PARSE_ERR_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
function normalizeTelegramDraftTransportPreview(
|
||||
preview: TelegramDraftPreview,
|
||||
): TelegramDraftTransportPreview {
|
||||
if (preview.richMessage?.html) {
|
||||
return {
|
||||
text: preview.richMessage.html,
|
||||
parseMode: "HTML",
|
||||
plainText: preview.text,
|
||||
};
|
||||
}
|
||||
if (preview.richMessage?.markdown) {
|
||||
return {
|
||||
text: renderTelegramHtmlText(preview.richMessage.markdown),
|
||||
parseMode: "HTML",
|
||||
plainText: preview.text,
|
||||
};
|
||||
}
|
||||
if (preview.parseMode === "HTML") {
|
||||
return {
|
||||
text: preview.text,
|
||||
parseMode: "HTML",
|
||||
plainText: telegramHtmlToPlainTextFallback(preview.text),
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: preview.text,
|
||||
plainText: preview.text,
|
||||
};
|
||||
}
|
||||
|
||||
function telegramDraftPreviewKey(preview: TelegramDraftPreview): string {
|
||||
return JSON.stringify(preview.richMessage);
|
||||
return JSON.stringify({
|
||||
text: preview.text,
|
||||
parseMode: preview.parseMode ?? "plain",
|
||||
richMessage: preview.richMessage,
|
||||
});
|
||||
}
|
||||
|
||||
function telegramDraftPreviewPayloadLength(preview: TelegramDraftPreview): number {
|
||||
const richMessage = preview.richMessage;
|
||||
return richMessage.html !== undefined ? richMessage.html.length : richMessage.markdown.length;
|
||||
function telegramDraftRichPayloadLength(preview: TelegramDraftPreview): number {
|
||||
const sourceMessage = preview.richMessage ?? { markdown: preview.text };
|
||||
if (!isTelegramRichMessageWithinStructuralLimits(sourceMessage)) {
|
||||
return TELEGRAM_RICH_TEXT_LIMIT + 1;
|
||||
}
|
||||
const richMessage = preview.richMessage ?? buildTelegramRichMarkdown(preview.text);
|
||||
return richMessage.html?.length ?? richMessage.markdown?.length ?? 0;
|
||||
}
|
||||
|
||||
function findTelegramDraftChunkLength(
|
||||
text: string,
|
||||
maxChars: number,
|
||||
renderText: ((text: string) => TelegramDraftPreview) | undefined,
|
||||
richMessages: boolean,
|
||||
): number {
|
||||
let best = 0;
|
||||
let low = 1;
|
||||
@@ -95,7 +147,11 @@ function findTelegramDraftChunkLength(
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
const preview = renderTelegramDraftPreview(text.slice(0, mid), renderText);
|
||||
if (preview.text.trimEnd() && telegramDraftPreviewPayloadLength(preview) <= maxChars) {
|
||||
const renderedText = normalizeTelegramDraftTransportPreview(preview).text.trimEnd();
|
||||
const payloadLength = richMessages
|
||||
? telegramDraftRichPayloadLength(preview)
|
||||
: renderedText.length;
|
||||
if (renderedText && payloadLength <= maxChars) {
|
||||
best = mid;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
@@ -111,6 +167,7 @@ export function createTelegramDraftStream(params: {
|
||||
maxChars?: number;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
replyToMessageId?: number;
|
||||
richMessages?: boolean;
|
||||
throttleMs?: number;
|
||||
/** Minimum chars before sending first message (debounce for push notifications) */
|
||||
minInitialChars?: number;
|
||||
@@ -121,16 +178,25 @@ export function createTelegramDraftStream(params: {
|
||||
log?: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
}): TelegramDraftStream {
|
||||
const maxChars = Math.min(
|
||||
params.maxChars ?? TELEGRAM_STREAM_MAX_CHARS,
|
||||
TELEGRAM_STREAM_MAX_CHARS,
|
||||
);
|
||||
const richMessages = params.richMessages === true;
|
||||
const transportLimit = richMessages ? TELEGRAM_RICH_TEXT_LIMIT : TELEGRAM_STREAM_MAX_CHARS;
|
||||
const maxChars = Math.min(params.maxChars ?? transportLimit, transportLimit);
|
||||
const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS);
|
||||
const minInitialChars = params.minInitialChars;
|
||||
const chatId = params.chatId;
|
||||
const threadParams = buildTelegramThreadParams(params.thread);
|
||||
const replyToMessageId = normalizeTelegramReplyToMessageId(params.replyToMessageId);
|
||||
const richReplyParams: Omit<TelegramSendRichMessageParams, "chat_id" | "rich_message"> =
|
||||
const sendMessageParams =
|
||||
replyToMessageId != null
|
||||
? {
|
||||
...threadParams,
|
||||
reply_parameters: {
|
||||
message_id: replyToMessageId,
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
}
|
||||
: (threadParams ?? {});
|
||||
const richMessageParams: Omit<TelegramSendRichMessageParams, "chat_id" | "rich_message"> =
|
||||
replyToMessageId != null
|
||||
? {
|
||||
...threadParams,
|
||||
@@ -159,25 +225,60 @@ export function createTelegramDraftStream(params: {
|
||||
sendGeneration: number;
|
||||
};
|
||||
const sendRenderedMessage = async (preview: TelegramDraftPreview) => {
|
||||
const richRawApi = getTelegramRichRawApi(params.api);
|
||||
return await richRawApi.sendRichMessage({
|
||||
chat_id: chatId,
|
||||
rich_message: preview.richMessage,
|
||||
...richReplyParams,
|
||||
});
|
||||
if (richMessages) {
|
||||
return await getTelegramRichRawApi(params.api).sendRichMessage({
|
||||
chat_id: chatId,
|
||||
rich_message: preview.richMessage ?? buildTelegramRichMarkdown(preview.text),
|
||||
...richMessageParams,
|
||||
});
|
||||
}
|
||||
const transportPreview = normalizeTelegramDraftTransportPreview(preview);
|
||||
const sendPlain = async () =>
|
||||
await params.api.sendMessage(chatId, transportPreview.plainText, sendMessageParams);
|
||||
if (transportPreview.parseMode !== "HTML") {
|
||||
return await sendPlain();
|
||||
}
|
||||
try {
|
||||
return await params.api.sendMessage(chatId, transportPreview.text, {
|
||||
parse_mode: "HTML" as const,
|
||||
...sendMessageParams,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isTelegramHtmlParseError(err)) {
|
||||
throw err;
|
||||
}
|
||||
return await sendPlain();
|
||||
}
|
||||
};
|
||||
const sendMessageTransportPreview = async ({
|
||||
preview,
|
||||
sendGeneration,
|
||||
}: PreviewSendParams): Promise<boolean> => {
|
||||
const transportPreview = normalizeTelegramDraftTransportPreview(preview);
|
||||
if (typeof streamMessageId === "number") {
|
||||
streamVisibleSinceMs ??= Date.now();
|
||||
const richRawApi = getTelegramRichRawApi(params.api);
|
||||
await richRawApi.editMessageText({
|
||||
chat_id: chatId,
|
||||
message_id: streamMessageId,
|
||||
rich_message: preview.richMessage,
|
||||
});
|
||||
if (richMessages) {
|
||||
await getTelegramRichRawApi(params.api).editMessageText({
|
||||
chat_id: chatId,
|
||||
message_id: streamMessageId,
|
||||
rich_message: preview.richMessage ?? buildTelegramRichMarkdown(preview.text),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (transportPreview.parseMode === "HTML") {
|
||||
try {
|
||||
await params.api.editMessageText(chatId, streamMessageId, transportPreview.text, {
|
||||
parse_mode: "HTML" as const,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isTelegramHtmlParseError(err)) {
|
||||
throw err;
|
||||
}
|
||||
await params.api.editMessageText(chatId, streamMessageId, transportPreview.plainText);
|
||||
}
|
||||
} else {
|
||||
await params.api.editMessageText(chatId, streamMessageId, transportPreview.text);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
messageSendAttempted = true;
|
||||
@@ -239,15 +340,23 @@ export function createTelegramDraftStream(params: {
|
||||
deliveredTextOffset === 0 && lastRequestedPreview?.text === trimmed
|
||||
? lastRequestedPreview
|
||||
: renderTelegramDraftPreview(currentText, params.renderText);
|
||||
const renderedText = rendered.text.trimEnd();
|
||||
const transportPreview = normalizeTelegramDraftTransportPreview(rendered);
|
||||
const renderedText = transportPreview.text.trimEnd();
|
||||
const renderedPayloadLength = richMessages
|
||||
? telegramDraftRichPayloadLength(rendered)
|
||||
: renderedText.length;
|
||||
const renderedPreview = { ...rendered, text: renderedText };
|
||||
const renderedPreviewKey = telegramDraftPreviewKey(renderedPreview);
|
||||
const renderedPayloadLength = telegramDraftPreviewPayloadLength(renderedPreview);
|
||||
if (!renderedText) {
|
||||
return false;
|
||||
}
|
||||
if (renderedPayloadLength > maxChars) {
|
||||
const chunkLength = findTelegramDraftChunkLength(currentText, maxChars, params.renderText);
|
||||
const chunkLength = findTelegramDraftChunkLength(
|
||||
currentText,
|
||||
maxChars,
|
||||
params.renderText,
|
||||
richMessages,
|
||||
);
|
||||
if (!streamState.final) {
|
||||
if (chunkLength > 0) {
|
||||
return await sendOrEditStreamMessage(
|
||||
|
||||
@@ -226,6 +226,35 @@ const TELEGRAM_ATTR_HTML_TAG_PATTERNS = new Map([
|
||||
const TELEGRAM_CODE_LANGUAGE_ATTR_PATTERN = /^\s+class="language-[^"]+"\s*$/;
|
||||
const TELEGRAM_RICH_TEXT_TABLE_COLUMN_LIMIT = 20;
|
||||
const TELEGRAM_VOID_HTML_TAGS = new Set(["br", "hr", "img", "input", "tg-map"]);
|
||||
const TELEGRAM_RICH_BLOCK_HTML_TAGS = new Set([
|
||||
"aside",
|
||||
"audio",
|
||||
"blockquote",
|
||||
"details",
|
||||
"figure",
|
||||
"footer",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hr",
|
||||
"img",
|
||||
"li",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"table",
|
||||
"tg-collage",
|
||||
"tg-map",
|
||||
"tg-math-block",
|
||||
"tg-slideshow",
|
||||
"tr",
|
||||
"ul",
|
||||
"video",
|
||||
]);
|
||||
const TELEGRAM_RICH_MEDIA_HTML_TAGS = new Set(["audio", "img", "video"]);
|
||||
const TELEGRAM_RICH_SIMPLE_HTML_TAGS = new Set([
|
||||
...TELEGRAM_SIMPLE_HTML_TAGS,
|
||||
"a",
|
||||
@@ -689,6 +718,49 @@ export function sanitizeTelegramRichHtml(html: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function limitTelegramRichHtmlNesting(html: string, maxDepth: number): string {
|
||||
const normalizedMaxDepth = Math.max(1, Math.floor(maxDepth));
|
||||
const stack: Array<{ name: string; kept: boolean }> = [];
|
||||
let keptDepth = 0;
|
||||
let output = "";
|
||||
let lastIndex = 0;
|
||||
|
||||
HTML_TAG_PATTERN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = HTML_TAG_PATTERN.exec(html)) !== null) {
|
||||
output += html.slice(lastIndex, match.index);
|
||||
const rawTag = match[0];
|
||||
const isClosing = match[1] === "</";
|
||||
const tagName = normalizeLowercaseStringOrEmpty(match[2]);
|
||||
const isSelfClosing =
|
||||
!isClosing && (TELEGRAM_VOID_HTML_TAGS.has(tagName) || rawTag.trimEnd().endsWith("/>"));
|
||||
|
||||
if (isClosing) {
|
||||
const entryIndex = stack.findLastIndex((entry) => entry.name === tagName);
|
||||
if (entryIndex >= 0) {
|
||||
const [entry] = stack.splice(entryIndex, 1);
|
||||
if (entry?.kept) {
|
||||
keptDepth = Math.max(0, keptDepth - 1);
|
||||
output += rawTag;
|
||||
}
|
||||
}
|
||||
} else if (isSelfClosing) {
|
||||
if (tagName === "br" || keptDepth < normalizedMaxDepth) {
|
||||
output += rawTag;
|
||||
}
|
||||
} else {
|
||||
const kept = keptDepth < normalizedMaxDepth;
|
||||
stack.push({ name: tagName, kept });
|
||||
if (kept) {
|
||||
keptDepth += 1;
|
||||
output += rawTag;
|
||||
}
|
||||
}
|
||||
lastIndex = HTML_TAG_PATTERN.lastIndex;
|
||||
}
|
||||
return output + html.slice(lastIndex);
|
||||
}
|
||||
|
||||
function normalizeTelegramRichMediaBlock(block: string): string {
|
||||
const normalized = block
|
||||
.trim()
|
||||
@@ -925,6 +997,8 @@ type TelegramHtmlTag = {
|
||||
name: string;
|
||||
openTag: string;
|
||||
closeTag: string;
|
||||
richBlock: boolean;
|
||||
richMedia: boolean;
|
||||
};
|
||||
|
||||
const TELEGRAM_SELF_CLOSING_HTML_TAGS = TELEGRAM_VOID_HTML_TAGS;
|
||||
@@ -945,6 +1019,13 @@ function buildTelegramHtmlCloseSuffixLength(tags: TelegramHtmlTag[]): number {
|
||||
return tags.reduce((total, tag) => total + tag.closeTag.length, 0);
|
||||
}
|
||||
|
||||
function isTelegramRichBlockHtmlTag(rawTag: string, tagName: string): boolean {
|
||||
return (
|
||||
TELEGRAM_RICH_BLOCK_HTML_TAGS.has(tagName) ||
|
||||
(tagName === "a" && /\sname="[^"]+"/i.test(rawTag))
|
||||
);
|
||||
}
|
||||
|
||||
function findTelegramHtmlEntityEnd(text: string, start: number): number {
|
||||
if (text[start] !== "&") {
|
||||
return -1;
|
||||
@@ -1018,22 +1099,34 @@ function popTelegramHtmlTag(tags: TelegramHtmlTag[], name: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function splitTelegramHtmlChunks(html: string, limit: number): string[] {
|
||||
export function splitTelegramHtmlChunks(
|
||||
html: string,
|
||||
limit: number,
|
||||
options: { blockLimit?: number; mediaLimit?: number } = {},
|
||||
): string[] {
|
||||
if (!html) {
|
||||
return [];
|
||||
}
|
||||
const normalizedLimit = Math.max(1, Math.floor(limit));
|
||||
if (html.length <= normalizedLimit) {
|
||||
const blockLimit =
|
||||
options.blockLimit == null ? undefined : Math.max(1, Math.floor(options.blockLimit));
|
||||
const mediaLimit =
|
||||
options.mediaLimit == null ? undefined : Math.max(1, Math.floor(options.mediaLimit));
|
||||
if (html.length <= normalizedLimit && blockLimit === undefined && mediaLimit === undefined) {
|
||||
return [html];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
const openTags: TelegramHtmlTag[] = [];
|
||||
let current = "";
|
||||
let currentBlockCount = 0;
|
||||
let currentMediaCount = 0;
|
||||
let chunkHasPayload = false;
|
||||
|
||||
const resetCurrent = () => {
|
||||
current = buildTelegramHtmlOpenPrefix(openTags);
|
||||
currentBlockCount = openTags.filter((tag) => tag.richBlock).length;
|
||||
currentMediaCount = openTags.filter((tag) => tag.richMedia).length;
|
||||
chunkHasPayload = false;
|
||||
};
|
||||
|
||||
@@ -1096,16 +1189,24 @@ export function splitTelegramHtmlChunks(html: string, limit: number): string[] {
|
||||
const isSelfClosing =
|
||||
!isClosing &&
|
||||
(TELEGRAM_SELF_CLOSING_HTML_TAGS.has(tagName) || rawTag.trimEnd().endsWith("/>"));
|
||||
const isRichBlock = !isClosing && isTelegramRichBlockHtmlTag(rawTag, tagName);
|
||||
const isRichMedia =
|
||||
!isClosing &&
|
||||
(tagName === "figure" ||
|
||||
(TELEGRAM_RICH_MEDIA_HTML_TAGS.has(tagName) &&
|
||||
!openTags.some((tag) => tag.name === "figure")));
|
||||
|
||||
if (!isClosing) {
|
||||
const nextCloseLength = isSelfClosing ? 0 : `</${tagName}>`.length;
|
||||
if (
|
||||
chunkHasPayload &&
|
||||
current.length +
|
||||
rawTag.length +
|
||||
buildTelegramHtmlCloseSuffixLength(openTags) +
|
||||
nextCloseLength >
|
||||
normalizedLimit
|
||||
((blockLimit !== undefined && isRichBlock && currentBlockCount >= blockLimit) ||
|
||||
(mediaLimit !== undefined && isRichMedia && currentMediaCount >= mediaLimit) ||
|
||||
current.length +
|
||||
rawTag.length +
|
||||
buildTelegramHtmlCloseSuffixLength(openTags) +
|
||||
nextCloseLength >
|
||||
normalizedLimit)
|
||||
) {
|
||||
flushCurrent();
|
||||
}
|
||||
@@ -1115,6 +1216,12 @@ export function splitTelegramHtmlChunks(html: string, limit: number): string[] {
|
||||
if (isSelfClosing) {
|
||||
chunkHasPayload = true;
|
||||
}
|
||||
if (isRichBlock) {
|
||||
currentBlockCount += 1;
|
||||
}
|
||||
if (isRichMedia) {
|
||||
currentMediaCount += 1;
|
||||
}
|
||||
if (isClosing) {
|
||||
popTelegramHtmlTag(openTags, tagName);
|
||||
} else if (!isSelfClosing) {
|
||||
@@ -1122,6 +1229,8 @@ export function splitTelegramHtmlChunks(html: string, limit: number): string[] {
|
||||
name: tagName,
|
||||
openTag: rawTag,
|
||||
closeTag: `</${tagName}>`,
|
||||
richBlock: isRichBlock,
|
||||
richMedia: isRichMedia,
|
||||
});
|
||||
}
|
||||
lastIndex = tagEnd;
|
||||
|
||||
@@ -505,11 +505,12 @@ describe("telegramOutbound", () => {
|
||||
cfg: {} as never,
|
||||
to: "12345",
|
||||
text: "hello",
|
||||
formatting: { parseMode: "HTML" },
|
||||
formatting: { parseMode: "HTML", tableMode: "bullets" },
|
||||
deps: { sendTelegram: sendMessageTelegramMock },
|
||||
});
|
||||
const options = lastCallOptions(sendMessageTelegramMock, "12345", "hello");
|
||||
expect(options.textMode).toBe("html");
|
||||
expect(options.tableMode).toBe("bullets");
|
||||
};
|
||||
const proveMedia = async () => {
|
||||
sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-media", chatId: "12345" });
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
normalizeMessagePresentation,
|
||||
renderMessagePresentationFallbackText,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import {
|
||||
resolvePayloadMediaUrls,
|
||||
sendPayloadMediaSequenceOrFallback,
|
||||
@@ -20,12 +21,12 @@ import {
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
import { resolveTelegramInlineButtons } from "./button-types.js";
|
||||
import { splitTelegramHtmlChunks } from "./format.js";
|
||||
import { resolveTelegramInteractiveTextFallback } from "./interactive-fallback.js";
|
||||
import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js";
|
||||
import { splitTelegramRichTextChunks, TELEGRAM_RICH_TEXT_LIMIT } from "./rich-message.js";
|
||||
import { normalizeTelegramOutboundTarget, parseTelegramTarget } from "./targets.js";
|
||||
|
||||
export const TELEGRAM_TEXT_CHUNK_LIMIT = TELEGRAM_RICH_TEXT_LIMIT;
|
||||
export const TELEGRAM_TEXT_CHUNK_LIMIT = 4000;
|
||||
export const TELEGRAM_POLL_OPTION_LIMIT = 10;
|
||||
|
||||
type TelegramSendFn = typeof import("./send.js").sendMessageTelegram;
|
||||
@@ -53,12 +54,9 @@ function chunkTelegramOutboundText(
|
||||
limit: number,
|
||||
ctx?: { formatting?: OutboundDeliveryFormattingOptions },
|
||||
): string[] {
|
||||
return splitTelegramRichTextChunks({
|
||||
text,
|
||||
textLimit: limit,
|
||||
textMode: ctx?.formatting?.parseMode === "HTML" ? "html" : "markdown",
|
||||
chunkMode: ctx?.formatting?.chunkMode ?? "length",
|
||||
});
|
||||
return ctx?.formatting?.parseMode === "HTML"
|
||||
? splitTelegramHtmlChunks(text, limit)
|
||||
: chunkMarkdownTextWithMode(text, limit, ctx?.formatting?.chunkMode ?? "length");
|
||||
}
|
||||
|
||||
async function resolveTelegramSendContext(params: {
|
||||
@@ -77,6 +75,7 @@ async function resolveTelegramSendContext(params: {
|
||||
cfg: NonNullable<TelegramSendOpts>["cfg"];
|
||||
verbose: false;
|
||||
textMode?: "html";
|
||||
tableMode?: OutboundDeliveryFormattingOptions["tableMode"];
|
||||
messageThreadId?: number;
|
||||
replyToMessageId?: number;
|
||||
accountId?: string;
|
||||
@@ -96,6 +95,7 @@ async function resolveTelegramSendContext(params: {
|
||||
silent: params.silent,
|
||||
gatewayClientScopes: params.gatewayClientScopes,
|
||||
...(params.formatting?.parseMode === "HTML" ? { textMode: "html" as const } : {}),
|
||||
tableMode: params.formatting?.tableMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -251,9 +251,7 @@ export function createTelegramOutboundAdapter(
|
||||
});
|
||||
},
|
||||
resolveEffectiveTextChunkLimit: ({ fallbackLimit }) =>
|
||||
typeof fallbackLimit === "number"
|
||||
? Math.min(fallbackLimit, TELEGRAM_RICH_TEXT_LIMIT)
|
||||
: TELEGRAM_RICH_TEXT_LIMIT,
|
||||
typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096,
|
||||
pollMaxOptions: TELEGRAM_POLL_OPTION_LIMIT,
|
||||
supportsPollDurationSeconds: true,
|
||||
supportsAnonymousPolls: true,
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import {
|
||||
escapeTelegramHtml,
|
||||
limitTelegramRichHtmlNesting,
|
||||
markdownToTelegramRichHtml,
|
||||
sanitizeTelegramRichHtml,
|
||||
splitTelegramHtmlChunks,
|
||||
@@ -25,6 +27,8 @@ type TelegramRichMessageReplyMarkup =
|
||||
|
||||
export const TELEGRAM_RICH_TEXT_LIMIT = 32_768;
|
||||
export const TELEGRAM_RICH_BLOCK_LIMIT = 500;
|
||||
export const TELEGRAM_RICH_MEDIA_LIMIT = 50;
|
||||
export const TELEGRAM_RICH_NESTING_LIMIT = 16;
|
||||
|
||||
export type TelegramInputRichMessage =
|
||||
| {
|
||||
@@ -49,7 +53,7 @@ export type TelegramRichTextMode = "markdown" | "html";
|
||||
|
||||
export type TelegramRichTextChunk = {
|
||||
text: string;
|
||||
textMode: TelegramRichTextMode;
|
||||
textMode: "html";
|
||||
plainText: string;
|
||||
};
|
||||
|
||||
@@ -166,7 +170,7 @@ export function buildTelegramRichHtml(
|
||||
html: string,
|
||||
options?: TelegramRichMessageOptions,
|
||||
): TelegramInputRichMessage {
|
||||
const safeHtml = sanitizeTelegramRichHtml(html);
|
||||
const safeHtml = prepareTelegramRichHtml(html);
|
||||
return options?.skipEntityDetection === true
|
||||
? { html: safeHtml, skip_entity_detection: true }
|
||||
: { html: safeHtml };
|
||||
@@ -182,6 +186,59 @@ export function buildTelegramRichMessage(
|
||||
: buildTelegramRichMarkdown(text, options);
|
||||
}
|
||||
|
||||
function prepareTelegramRichHtml(html: string): string {
|
||||
return limitTelegramRichHtmlNesting(sanitizeTelegramRichHtml(html), TELEGRAM_RICH_NESTING_LIMIT);
|
||||
}
|
||||
|
||||
const TELEGRAM_RICH_HTML_CHUNK_LIMITS = {
|
||||
blockLimit: TELEGRAM_RICH_BLOCK_LIMIT,
|
||||
mediaLimit: TELEGRAM_RICH_MEDIA_LIMIT,
|
||||
} as const;
|
||||
|
||||
function splitPreparedTelegramRichHtml(params: {
|
||||
html: string;
|
||||
sourceFallback: string;
|
||||
textLimit: number;
|
||||
}): string[] {
|
||||
try {
|
||||
const chunks = splitTelegramHtmlChunks(
|
||||
params.html,
|
||||
params.textLimit,
|
||||
TELEGRAM_RICH_HTML_CHUNK_LIMITS,
|
||||
);
|
||||
if (chunks.length > 0) {
|
||||
return chunks;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to readable source text when rich planning cannot preserve the payload.
|
||||
}
|
||||
return splitTelegramHtmlChunks(escapeTelegramHtml(params.sourceFallback), params.textLimit);
|
||||
}
|
||||
|
||||
export function isTelegramRichMessageWithinStructuralLimits(
|
||||
message: TelegramInputRichMessage,
|
||||
): boolean {
|
||||
if (message.markdown !== undefined) {
|
||||
if (splitTelegramRichMarkdownBlocks(message.markdown, TELEGRAM_RICH_BLOCK_LIMIT).length > 1) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
splitTelegramHtmlChunks(
|
||||
prepareTelegramRichHtml(markdownToTelegramRichHtml(message.markdown)),
|
||||
TELEGRAM_RICH_TEXT_LIMIT,
|
||||
TELEGRAM_RICH_HTML_CHUNK_LIMITS,
|
||||
).length <= 1
|
||||
);
|
||||
}
|
||||
return (
|
||||
splitTelegramHtmlChunks(
|
||||
prepareTelegramRichHtml(message.html),
|
||||
TELEGRAM_RICH_TEXT_LIMIT,
|
||||
TELEGRAM_RICH_HTML_CHUNK_LIMITS,
|
||||
).length <= 1
|
||||
);
|
||||
}
|
||||
|
||||
type RichMarkdownFenceSpan = {
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -352,7 +409,11 @@ export function splitTelegramRichTextChunks(params: {
|
||||
chunkMode: ChunkMode;
|
||||
}): string[] {
|
||||
return params.textMode === "html"
|
||||
? splitTelegramHtmlChunks(sanitizeTelegramRichHtml(params.text), params.textLimit)
|
||||
? splitTelegramHtmlChunks(
|
||||
prepareTelegramRichHtml(params.text),
|
||||
params.textLimit,
|
||||
TELEGRAM_RICH_HTML_CHUNK_LIMITS,
|
||||
)
|
||||
: splitTelegramRichMarkdownChunks(params.text, params.textLimit, params.chunkMode);
|
||||
}
|
||||
|
||||
@@ -365,15 +426,26 @@ export function splitTelegramRichMessageTextChunks(params: {
|
||||
skipEntityDetection?: boolean;
|
||||
}): TelegramRichTextChunk[] {
|
||||
const renderMarkdownChunk = (chunk: string) =>
|
||||
markdownToTelegramRichHtml(chunk, {
|
||||
tableMode: params.tableMode,
|
||||
skipEntityDetection: params.skipEntityDetection,
|
||||
});
|
||||
prepareTelegramRichHtml(
|
||||
markdownToTelegramRichHtml(chunk, {
|
||||
tableMode: params.tableMode,
|
||||
skipEntityDetection: params.skipEntityDetection,
|
||||
}),
|
||||
);
|
||||
const htmlChunks =
|
||||
params.textMode === "html"
|
||||
? splitTelegramHtmlChunks(sanitizeTelegramRichHtml(params.text), params.textLimit)
|
||||
? splitPreparedTelegramRichHtml({
|
||||
html: prepareTelegramRichHtml(params.text),
|
||||
sourceFallback: params.text,
|
||||
textLimit: params.textLimit,
|
||||
})
|
||||
: splitTelegramRichMarkdownChunks(params.text, params.textLimit, params.chunkMode).flatMap(
|
||||
(chunk) => splitTelegramHtmlChunks(renderMarkdownChunk(chunk), params.textLimit),
|
||||
(chunk) =>
|
||||
splitPreparedTelegramRichHtml({
|
||||
html: renderMarkdownChunk(chunk),
|
||||
sourceFallback: chunk,
|
||||
textLimit: params.textLimit,
|
||||
}),
|
||||
);
|
||||
return htmlChunks.map((chunk) => ({
|
||||
text: chunk,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { markdownToTelegramHtml, markdownToTelegramRichHtml } from "./format.js";
|
||||
import { markdownToTelegramHtml } from "./format.js";
|
||||
import {
|
||||
buildTelegramConversationContext,
|
||||
createTelegramMessageCache,
|
||||
@@ -65,11 +65,6 @@ const {
|
||||
} = telegramSendModule;
|
||||
const sendMessageTelegramImpl = sendMessageTelegramImported;
|
||||
|
||||
type RichSendCallParams = {
|
||||
rich_message?: { markdown?: string; html?: string };
|
||||
reply_markup?: unknown;
|
||||
};
|
||||
|
||||
type RichRawTextTestApi = Omit<TelegramApiOverride, "raw" | "sendMessage"> & {
|
||||
raw?: {
|
||||
sendRichMessage?: (params: {
|
||||
@@ -91,8 +86,8 @@ function richTextForTest(richMessage: { markdown?: string; html?: string }): str
|
||||
: (richMessage.html ?? "");
|
||||
}
|
||||
|
||||
function richSendCallParams(): RichSendCallParams[] {
|
||||
return botRawApi.sendRichMessage.mock.calls.map(([params]) => params);
|
||||
function sendMessageTexts(mockFn: typeof botApi.sendMessage): string[] {
|
||||
return mockFn.mock.calls.map((call) => String(call[1] ?? ""));
|
||||
}
|
||||
|
||||
function withRichRawTextTestApi(
|
||||
@@ -129,7 +124,7 @@ const sendMessageTelegram: typeof sendMessageTelegramImpl = async (to, text, opt
|
||||
: opts,
|
||||
);
|
||||
|
||||
const TELEGRAM_TEST_CFG = { channels: { telegram: { markdown: { tables: "block" as const } } } };
|
||||
const TELEGRAM_TEST_CFG = {};
|
||||
let sentMessageStore: NonNullable<Parameters<typeof setTelegramSentMessageStoreForTest>[0]>;
|
||||
|
||||
function markdownTable(columns: number): string {
|
||||
@@ -142,6 +137,22 @@ function markdownTable(columns: number): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function markdownTableWithRows(rows: number): string {
|
||||
return [
|
||||
"| Name | Value |",
|
||||
"| --- | --- |",
|
||||
...Array.from({ length: rows }, (_, index) => `| row ${index} | ${index} |`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function countTelegramRichHtmlBlocks(html: string): number {
|
||||
return (
|
||||
html.match(
|
||||
/<(?:aside|audio|blockquote|details|figure|footer|h[1-6]|hr|img|li|ol|p|pre|table|tg-collage|tg-map|tg-math-block|tg-slideshow|tr|ul|video)\b/gi,
|
||||
)?.length ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetPluginStateStoreForTests({ closeDatabase: false });
|
||||
installTelegramStateRuntimeForTest();
|
||||
@@ -882,13 +893,15 @@ describe("sendMessageTelegram", () => {
|
||||
expect(res.messageId).toBe("44");
|
||||
});
|
||||
|
||||
it("skips rich entity detection when link previews are disabled", async () => {
|
||||
it("disables link previews on the text send path", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "html send succeeds",
|
||||
text: "hi",
|
||||
sendMessage: vi.fn().mockResolvedValue({ message_id: 7, chat: { id: "123" } }),
|
||||
expectedCalls: [["123", "hi", { parse_mode: "HTML", skip_entity_detection: true }]],
|
||||
expectedCalls: [
|
||||
["123", "hi", { parse_mode: "HTML", link_preview_options: { is_disabled: true } }],
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
@@ -908,7 +921,7 @@ describe("sendMessageTelegram", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("sends Markdown durable text as Telegram rich HTML", async () => {
|
||||
it("sends formatted HTML for durable text", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
|
||||
|
||||
await sendMessageTelegram("123", "**hi**", {
|
||||
@@ -916,13 +929,122 @@ describe("sendMessageTelegram", () => {
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
rich_message: { html: "<b>hi</b>" },
|
||||
expect(botApi.sendMessage).toHaveBeenCalledWith("123", "<b>hi</b>", {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends complex Markdown through Telegram rich HTML", async () => {
|
||||
it("sends native rich tables when explicitly enabled", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
|
||||
const markdown = markdownTable(3);
|
||||
|
||||
await sendMessageTelegram("123", markdown, {
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
richMessages: true,
|
||||
markdown: { tables: "block" },
|
||||
},
|
||||
},
|
||||
},
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
|
||||
expect(richMessage?.html).toContain("<table>");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "list",
|
||||
text: `<ul>${Array.from({ length: 501 }, (_, index) => `<li>item ${index}</li>`).join("")}</ul>`,
|
||||
textMode: "html" as const,
|
||||
terminalText: "item 500",
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
text: markdownTableWithRows(501),
|
||||
textMode: "markdown" as const,
|
||||
terminalText: "row 500",
|
||||
},
|
||||
])("chunks rich $name output at Telegram's block limit", async (testCase) => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
|
||||
|
||||
await sendMessageTelegram("123", testCase.text, {
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
richMessages: true,
|
||||
markdown: { tables: "block" },
|
||||
},
|
||||
},
|
||||
},
|
||||
token: "tok",
|
||||
textMode: testCase.textMode,
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage.mock.calls.length).toBeGreaterThan(1);
|
||||
const htmlChunks = botRawApi.sendRichMessage.mock.calls.map(
|
||||
(call) => call[0]?.rich_message.html ?? "",
|
||||
);
|
||||
for (const html of htmlChunks) {
|
||||
expect(countTelegramRichHtmlBlocks(html)).toBeLessThanOrEqual(500);
|
||||
}
|
||||
expect(htmlChunks.join("\n")).toContain(testCase.terminalText);
|
||||
});
|
||||
|
||||
it("chunks rich media at Telegram's attachment limit", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
|
||||
const html = Array.from(
|
||||
{ length: 51 },
|
||||
(_, index) => `<img src="https://example.com/${index}.png" alt="image ${index}"/>`,
|
||||
).join("");
|
||||
|
||||
await sendMessageTelegram("123", html, {
|
||||
cfg: { channels: { telegram: { richMessages: true } } },
|
||||
token: "tok",
|
||||
textMode: "html",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage.mock.calls.length).toBe(2);
|
||||
for (const call of botRawApi.sendRichMessage.mock.calls) {
|
||||
const richHtml = call[0]?.rich_message.html ?? "";
|
||||
expect(richHtml.match(/<img\b/gi)?.length ?? 0).toBeLessThanOrEqual(50);
|
||||
}
|
||||
});
|
||||
|
||||
it("flattens rich HTML beyond Telegram's nesting limit", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
|
||||
const html = `${"<b>".repeat(20)}nested<br>line${"</b>".repeat(20)}`;
|
||||
|
||||
await sendMessageTelegram("123", html, {
|
||||
cfg: { channels: { telegram: { richMessages: true } } },
|
||||
token: "tok",
|
||||
textMode: "html",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
const richHtml = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message.html ?? "";
|
||||
expect(richHtml.match(/<b>/g)?.length ?? 0).toBe(16);
|
||||
expect(richHtml).toContain("nested<br>line");
|
||||
});
|
||||
|
||||
it("preserves nonempty Markdown when rich rendering is empty", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
|
||||
const markdown = "[reference]: https://example.com";
|
||||
|
||||
await sendMessageTelegram("123", markdown, {
|
||||
cfg: { channels: { telegram: { richMessages: true } } },
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message.html).toBe(markdown);
|
||||
});
|
||||
|
||||
it("renders complex markdown into HTML text", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 46, chat: { id: "123" } });
|
||||
const markdown = [
|
||||
"# Heading",
|
||||
@@ -941,123 +1063,47 @@ describe("sendMessageTelegram", () => {
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
rich_message: { html: markdownToTelegramRichHtml(markdown) },
|
||||
});
|
||||
expect(botApi.sendMessage).toHaveBeenCalledTimes(1);
|
||||
const [chatId, sentText, sentOptions] = botApi.sendMessage.mock.calls.at(-1) ?? [];
|
||||
expect(chatId).toBe("123");
|
||||
expect(String(sentText)).toContain("<blockquote>");
|
||||
expect(String(sentText)).toContain("<tg-spoiler>spoiler</tg-spoiler>");
|
||||
expect(String(sentText)).toContain('<a href="https://example.com">link</a>');
|
||||
expect(sentOptions).toEqual({ parse_mode: "HTML" });
|
||||
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not pass currency through Telegram Rich Markdown math parsing", async () => {
|
||||
it("renders markdown media syntax on the text path", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 47, chat: { id: "123" } });
|
||||
const text =
|
||||
"10Y realistic strong outcome: ~$400-600K TC, top end ($800K+) gated on frontier lab equity.";
|
||||
|
||||
await sendMessageTelegram("123", text, {
|
||||
await sendMessageTelegram("123", "See ", {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
rich_message: {
|
||||
html: markdownToTelegramRichHtml(text),
|
||||
},
|
||||
});
|
||||
const richMessage = richSendCallParams()[0]?.rich_message;
|
||||
expect(richMessage?.html).toContain("$400-600K");
|
||||
expect(richMessage?.html).toContain("($800K+)");
|
||||
expect(richMessage?.markdown).toBeUndefined();
|
||||
expect(botApi.sendMessage).toHaveBeenCalledWith("123", "See diagram", { parse_mode: "HTML" });
|
||||
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves line breaks outside fenced code through rich HTML", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 47, chat: { id: "123" } });
|
||||
const markdown = [
|
||||
"Status: ok | mode",
|
||||
"Models: ready",
|
||||
"",
|
||||
"```",
|
||||
"a",
|
||||
"b",
|
||||
"```",
|
||||
"Tail",
|
||||
].join("\n");
|
||||
|
||||
await sendMessageTelegram("123", markdown, {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
rich_message: { html: markdownToTelegramRichHtml(markdown) },
|
||||
});
|
||||
});
|
||||
|
||||
it("isolates supported rich HTML media tags as blocks", async () => {
|
||||
it("escapes HTML media tags on the text path", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 48, chat: { id: "123" } });
|
||||
const html = '<b>See</b><img src="https://example.com/diagram.png">';
|
||||
const expectedHtml =
|
||||
'<b>See</b>\n\n<figure><img src="https://example.com/diagram.png"/></figure>';
|
||||
|
||||
await sendMessageTelegram("123", html, {
|
||||
await sendMessageTelegram("123", '<b>See</b><img src="https://example.com/diagram.png">', {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
textMode: "html",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
rich_message: { html: expectedHtml },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves supported Telegram rich HTML structures", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 49, chat: { id: "123" } });
|
||||
const html = [
|
||||
"<h2>Plan</h2>",
|
||||
"<details><summary>More</summary><p>Hidden</p></details>",
|
||||
"<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>B</td></tr></tbody></table>",
|
||||
'<figure tg-spoiler><img src="https://example.com/diagram.png" alt="diagram"></figure>',
|
||||
"<tg-math>x^2 + y^2</tg-math>",
|
||||
].join("");
|
||||
|
||||
await sendMessageTelegram("123", html, {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
textMode: "html",
|
||||
});
|
||||
|
||||
const renderedHtml = richSendCallParams()[0]?.rich_message?.html ?? "";
|
||||
expect(renderedHtml).toContain("<h2>Plan</h2>");
|
||||
expect(renderedHtml).toContain("<details><summary>More</summary><p>Hidden</p></details>");
|
||||
expect(renderedHtml).toContain("<table>");
|
||||
expect(renderedHtml).toContain(
|
||||
'\n\n<figure tg-spoiler><img src="https://example.com/diagram.png" alt="diagram"/></figure>\n\n',
|
||||
expect(botApi.sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
'<b>See</b><img src="https://example.com/diagram.png">',
|
||||
{ parse_mode: "HTML" },
|
||||
);
|
||||
expect(renderedHtml).toContain("<tg-math>x^2 + y^2</tg-math>");
|
||||
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends raw rich HTML tags through Telegram rich HTML", async () => {
|
||||
it("keeps markdown tables within Telegram's HTML text path", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 49, chat: { id: "123" } });
|
||||
const markdown = [
|
||||
'<img src="https://example.com/diagram.png" alt="Diagram">',
|
||||
"<details><summary>More</summary>Hidden</details>",
|
||||
"<sup>1</sup>",
|
||||
'<input type="checkbox" checked>',
|
||||
].join(" ");
|
||||
|
||||
await sendMessageTelegram("123", markdown, {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(richSendCallParams()[0]?.rich_message).toEqual({
|
||||
html: markdownToTelegramRichHtml(markdown),
|
||||
});
|
||||
});
|
||||
|
||||
it("sends Markdown tables within Telegram's column limit as rich HTML tables", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 50, chat: { id: "123" } });
|
||||
const markdown = markdownTable(20);
|
||||
|
||||
await sendMessageTelegram("123", markdown, {
|
||||
@@ -1065,37 +1111,13 @@ describe("sendMessageTelegram", () => {
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
rich_message: { html: markdownToTelegramRichHtml(markdown) },
|
||||
});
|
||||
expect(richSendCallParams()[0]?.rich_message?.html).toContain("<table>");
|
||||
expect(botApi.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageTexts(botApi.sendMessage).join("")).toContain("| H1 | H2 |");
|
||||
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not auto-linkify Markdown URLs when link previews are disabled", async () => {
|
||||
it("wraps wide markdown tables for the HTML text path", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 50, chat: { id: "123" } });
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
markdown: { tables: "block" as const },
|
||||
linkPreview: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await sendMessageTelegram("123", "https://example.com", {
|
||||
cfg,
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(richSendCallParams()[0]?.rich_message).toEqual({
|
||||
html: "https://example.com",
|
||||
skip_entity_detection: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders wide Markdown tables as code blocks when they exceed Telegram's column limit", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 51, chat: { id: "123" } });
|
||||
const markdown = markdownTable(21);
|
||||
|
||||
await sendMessageTelegram("123", markdown, {
|
||||
@@ -1103,15 +1125,15 @@ describe("sendMessageTelegram", () => {
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
rich_message: { html: markdownToTelegramRichHtml(markdown) },
|
||||
});
|
||||
expect(richSendCallParams()[0]?.rich_message?.html).toContain("<pre><code>");
|
||||
expect(botApi.sendMessage).toHaveBeenCalledTimes(1);
|
||||
const sent = sendMessageTexts(botApi.sendMessage).join("");
|
||||
expect(sent).toContain("<pre><code>");
|
||||
expect(sent).toContain("| H21 |");
|
||||
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders fenced wide Markdown tables as code in rich HTML", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 52, chat: { id: "123" } });
|
||||
it("leaves wide fenced tables intact on the HTML text path", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 51, chat: { id: "123" } });
|
||||
const markdown = `~~~\n${markdownTable(25)}\n~~~`;
|
||||
|
||||
await sendMessageTelegram("123", markdown, {
|
||||
@@ -1119,13 +1141,12 @@ describe("sendMessageTelegram", () => {
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
rich_message: { html: markdownToTelegramRichHtml(markdown) },
|
||||
});
|
||||
expect(botApi.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageTexts(botApi.sendMessage).join("")).toContain(markdownTable(25));
|
||||
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back only wide Markdown tables outside fences", async () => {
|
||||
it("wraps only wide markdown tables outside fences on the HTML text path", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 52, chat: { id: "123" } });
|
||||
const fencedTable = markdownTable(25);
|
||||
const outsideTable = markdownTable(21);
|
||||
@@ -1136,29 +1157,30 @@ describe("sendMessageTelegram", () => {
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
rich_message: { html: markdownToTelegramRichHtml(markdown) },
|
||||
});
|
||||
expect(botApi.sendMessage).toHaveBeenCalledTimes(1);
|
||||
const sent = sendMessageTexts(botApi.sendMessage).join("");
|
||||
expect(sent).toContain("Before");
|
||||
expect(sent).toContain(fencedTable);
|
||||
expect(sent).toContain("<pre><code>");
|
||||
expect(sent).toContain("| H21 |");
|
||||
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("chunks long rich HTML when source text exceeds the message limit", async () => {
|
||||
it("sends medium markdown text as one HTML message", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 53, chat: { id: "123" } });
|
||||
const line = "**section** with _style_ and `code`";
|
||||
const markdown = `# Long\n\n${`${line}\n`.repeat(2000)}`;
|
||||
const markdown = `# Long\n\n${"**section** with _style_ and `code`\n".repeat(800)}`;
|
||||
|
||||
await sendMessageTelegram("123", markdown, {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(chunks.every((chunk) => chunk.length <= 32_768)).toBe(true);
|
||||
expect(chunks.join("").match(/<b>section<\/b>/g)).toHaveLength(2000);
|
||||
expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(sendMessageTexts(botApi.sendMessage).join("")).toContain("section");
|
||||
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("chunks rich HTML above the Bot API rich message limit", async () => {
|
||||
it("chunks markdown above the Telegram text-message limit", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 54, chat: { id: "123" } });
|
||||
const markdown = `# Long\n\n${"**section** with _style_ and `code`\n".repeat(3000)}`;
|
||||
|
||||
@@ -1167,14 +1189,15 @@ describe("sendMessageTelegram", () => {
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage.mock.calls.length).toBeGreaterThan(1);
|
||||
const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
|
||||
expect(chunks.at(0)).toContain("Long");
|
||||
expect(chunks.join("").match(/<b>section<\/b>/g)).toHaveLength(3000);
|
||||
expect(chunks.every((chunk) => chunk.length <= 32_768)).toBe(true);
|
||||
expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
|
||||
const chunks = sendMessageTexts(botApi.sendMessage);
|
||||
const joinedChunks = chunks.join("");
|
||||
expect(joinedChunks).toContain("Long");
|
||||
expect(joinedChunks).toContain("section");
|
||||
expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true);
|
||||
});
|
||||
|
||||
it("chunks long inline Markdown as bounded rich HTML", async () => {
|
||||
it("chunks long inline markdown through the HTML text path", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 52, chat: { id: "123" } });
|
||||
const markdown = `**${"A".repeat(70_000)}**`;
|
||||
|
||||
@@ -1183,14 +1206,13 @@ describe("sendMessageTelegram", () => {
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
const chunks = richSendCallParams().map((params) => params.rich_message);
|
||||
const chunks = sendMessageTexts(botApi.sendMessage);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(chunks.every((chunk) => chunk?.markdown === undefined)).toBe(true);
|
||||
expect(chunks.every((chunk) => (chunk?.html ?? "").length <= 32_768)).toBe(true);
|
||||
expect(chunks.map((chunk) => chunk?.html ?? "").join("")).toContain("A".repeat(100));
|
||||
expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true);
|
||||
expect(chunks.join("")).toContain("A");
|
||||
});
|
||||
|
||||
it("chunks rich markdown above Telegram's rich block limit", async () => {
|
||||
it("chunks long markdown paragraphs on the text path", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 53, chat: { id: "123" } });
|
||||
const markdown = Array.from({ length: 900 }, (_, index) => `Paragraph ${index + 1}`).join(
|
||||
"\n\n",
|
||||
@@ -1201,13 +1223,12 @@ describe("sendMessageTelegram", () => {
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
|
||||
expect(chunks).toHaveLength(2);
|
||||
expect(chunks.every((chunk) => (chunk.match(/Paragraph \d+/g)?.length ?? 0) <= 500)).toBe(true);
|
||||
expect(chunks.join("").match(/Paragraph \d+/g)).toHaveLength(900);
|
||||
const chunks = sendMessageTexts(botApi.sendMessage);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true);
|
||||
});
|
||||
|
||||
it("chunks rich markdown headings above Telegram's rich block limit", async () => {
|
||||
it("chunks long markdown headings on the text path", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 54, chat: { id: "123" } });
|
||||
const markdown = Array.from({ length: 600 }, (_, index) => `# Heading ${index + 1}`).join("\n");
|
||||
|
||||
@@ -1216,13 +1237,12 @@ describe("sendMessageTelegram", () => {
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
|
||||
expect(chunks).toHaveLength(2);
|
||||
expect(chunks.at(0)?.match(/Heading \d+/g)).toHaveLength(500);
|
||||
expect(chunks.at(1)?.match(/Heading \d+/g)).toHaveLength(100);
|
||||
const chunks = sendMessageTexts(botApi.sendMessage);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(chunks.join("")).toContain("Heading 600");
|
||||
});
|
||||
|
||||
it("keeps long rich markdown lists intact", async () => {
|
||||
it("keeps long markdown lists on the text path", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 55, chat: { id: "123" } });
|
||||
const markdown = Array.from({ length: 600 }, (_, index) => `- Item ${index + 1}`).join("\n");
|
||||
|
||||
@@ -1231,14 +1251,12 @@ describe("sendMessageTelegram", () => {
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
rich_message: { html: markdownToTelegramRichHtml(markdown) },
|
||||
});
|
||||
expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(sendMessageTexts(botApi.sendMessage).join("")).toContain("Item 600");
|
||||
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps tall rich markdown tables intact", async () => {
|
||||
it("keeps tall markdown tables on the text path", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 56, chat: { id: "123" } });
|
||||
const markdown = [
|
||||
"| Name | Value |",
|
||||
@@ -1251,14 +1269,12 @@ describe("sendMessageTelegram", () => {
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
rich_message: { html: markdownToTelegramRichHtml(markdown) },
|
||||
});
|
||||
expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(sendMessageTexts(botApi.sendMessage).join("")).toContain("Row 600");
|
||||
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not split rich block chunks on blank lines inside fences", async () => {
|
||||
it("does not split fenced blocks unnecessarily on the text path", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 57, chat: { id: "123" } });
|
||||
const markdown = `~~~txt\n${Array.from({ length: 900 }, (_, index) => `line ${index + 1}`).join(
|
||||
"\n\n",
|
||||
@@ -1269,14 +1285,12 @@ describe("sendMessageTelegram", () => {
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
rich_message: { html: markdownToTelegramRichHtml(markdown) },
|
||||
});
|
||||
expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(sendMessageTexts(botApi.sendMessage).join("")).toContain("line 900");
|
||||
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not split rich heading chunks inside fences", async () => {
|
||||
it("does not split fenced headings unnecessarily on the text path", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 58, chat: { id: "123" } });
|
||||
const markdown = `~~~md\n${Array.from(
|
||||
{ length: 600 },
|
||||
@@ -1288,14 +1302,12 @@ describe("sendMessageTelegram", () => {
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
rich_message: { html: markdownToTelegramRichHtml(markdown) },
|
||||
});
|
||||
expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(sendMessageTexts(botApi.sendMessage).join("")).toContain("Literal heading 600");
|
||||
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("chunks long rich markdown fences into bounded rich HTML chunks", async () => {
|
||||
it("chunks long fenced markdown into bounded text chunks", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 59, chat: { id: "123" } });
|
||||
const markdown = `~~~ts\n${"const value = 1;\n".repeat(5000)}~~~`;
|
||||
|
||||
@@ -1304,13 +1316,12 @@ describe("sendMessageTelegram", () => {
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
|
||||
const chunks = sendMessageTexts(botApi.sendMessage);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(chunks.every((chunk) => chunk.length <= 32_768)).toBe(true);
|
||||
expect(chunks.join("")).toContain("const value = 1;");
|
||||
expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true);
|
||||
});
|
||||
|
||||
it("chunks explicit rich HTML above the Bot API rich message limit", async () => {
|
||||
it("chunks explicit HTML above the Telegram text-message limit", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 60, chat: { id: "123" } });
|
||||
const html = `<b>${"A".repeat(70_000)}</b>`;
|
||||
|
||||
@@ -1321,42 +1332,14 @@ describe("sendMessageTelegram", () => {
|
||||
buttons: [[{ text: "OK", callback_data: "ok" }]],
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage.mock.calls.length).toBeGreaterThan(1);
|
||||
const calls = richSendCallParams();
|
||||
expect(calls.every((params) => (params.rich_message?.html ?? "").length <= 32_768)).toBe(true);
|
||||
expect(calls.at(0)?.rich_message?.html).toMatch(/^<b>A/);
|
||||
expect(calls.at(-1)?.rich_message?.html).toMatch(/A<\/b>$/);
|
||||
expect(calls.slice(0, -1).every((params) => params.reply_markup === undefined)).toBe(true);
|
||||
expect(calls.at(-1)?.reply_markup).toEqual({
|
||||
expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
|
||||
const lastParams = botApi.sendMessage.mock.calls.at(-1)?.[2];
|
||||
expect(sendMessageTexts(botApi.sendMessage).every((chunk) => chunk.length <= 4000)).toBe(true);
|
||||
expect(requireRecord(lastParams, "last sendMessage params").reply_markup).toEqual({
|
||||
inline_keyboard: [[{ text: "OK", callback_data: "ok" }]],
|
||||
});
|
||||
});
|
||||
|
||||
it("chunks explicit rich HTML after media normalization", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 61, chat: { id: "123" } });
|
||||
const img = '<img src="https://example.com/a.png">';
|
||||
const html = img.repeat(3);
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
markdown: { tables: "block" as const },
|
||||
textChunkLimit: html.length + 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await sendMessageTelegram("123", html, {
|
||||
cfg,
|
||||
token: "tok",
|
||||
textMode: "html",
|
||||
});
|
||||
|
||||
const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(chunks.every((chunk) => chunk.length <= html.length + 5)).toBe(true);
|
||||
expect(chunks.join("")).toContain("<figure>");
|
||||
});
|
||||
|
||||
it("fails when Telegram text send returns no message_id", async () => {
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
chat: { id: "123" },
|
||||
@@ -1612,7 +1595,7 @@ describe("sendMessageTelegram", () => {
|
||||
expectMediaSendCall(firstMockCall(sendPhoto, "send photo call"), "send photo call", chatId, {
|
||||
caption: undefined,
|
||||
});
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessage.mock.calls.every((call) => call[2]?.parse_mode === "HTML")).toBe(true);
|
||||
expect(sendMessage.mock.calls.map((call) => String(call[1] ?? "")).join("")).toContain("A");
|
||||
expect(res.messageId).toBe("74");
|
||||
@@ -1918,7 +1901,16 @@ describe("sendMessageTelegram", () => {
|
||||
chatId,
|
||||
testCase.expectedVideoNote,
|
||||
);
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, testCase.text, testCase.expectedMessage);
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, testCase.text, {
|
||||
...testCase.expectedMessage,
|
||||
...(testCase.expectedMessage?.reply_parameters
|
||||
? {
|
||||
reply_to_message_id: 999,
|
||||
allow_sending_without_reply: true,
|
||||
reply_parameters: undefined,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2593,7 +2585,7 @@ describe("sendMessageTelegram", () => {
|
||||
expect(logs).toContain("accountId=ops");
|
||||
expect(logs).toContain(`chatId=${chatId}`);
|
||||
expect(logs).toContain("messageId=321");
|
||||
expect(logs).toContain("operation=sendRichMessage");
|
||||
expect(logs).toContain("operation=sendMessage");
|
||||
expect(logs).toContain("threadId=271");
|
||||
expect(logs).toContain("replyToMessageId=123");
|
||||
expect(logs).toContain("silent=true");
|
||||
@@ -2797,10 +2789,10 @@ describe("sendMessageTelegram", () => {
|
||||
buttons: [[{ text: "OK", callback_data: "ok" }]],
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
const firstCall = firstMockCall(sendMessage, "first sendMessage call");
|
||||
const firstParams = requireRecord(firstCall[2], "first sendMessage params");
|
||||
expect(firstParams.reply_markup).toEqual({
|
||||
expect(sendMessage.mock.calls.length).toBeGreaterThan(1);
|
||||
const lastCall = sendMessage.mock.calls.at(-1);
|
||||
const lastParams = requireRecord(lastCall?.[2], "last sendMessage params");
|
||||
expect(lastParams.reply_markup).toEqual({
|
||||
inline_keyboard: [[{ text: "OK", callback_data: "ok" }]],
|
||||
});
|
||||
expect(res.messageId).toBe("91");
|
||||
@@ -2820,13 +2812,15 @@ describe("sendMessageTelegram", () => {
|
||||
buttons: [[{ text: "OK", callback_data: "ok" }]],
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage.mock.calls.length).toBeGreaterThan(1);
|
||||
const firstCall = firstMockCall(sendMessage, "first sendMessage call");
|
||||
const firstParams = requireRecord(firstCall[2], "first sendMessage params");
|
||||
const firstText = requireString(firstCall[1], "first sendMessage text");
|
||||
expect(firstParams.parse_mode).toBe("HTML");
|
||||
expect(firstText).toContain("A");
|
||||
expect(firstParams.reply_markup).toEqual({
|
||||
const lastCall = sendMessage.mock.calls.at(-1);
|
||||
const lastParams = requireRecord(lastCall?.[2], "last sendMessage params");
|
||||
expect(lastParams.reply_markup).toEqual({
|
||||
inline_keyboard: [[{ text: "OK", callback_data: "ok" }]],
|
||||
});
|
||||
expect(res.messageId).toBe("91");
|
||||
@@ -3116,10 +3110,8 @@ describe("shared send behaviors", () => {
|
||||
});
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, "reply text", {
|
||||
parse_mode: "HTML",
|
||||
reply_parameters: {
|
||||
message_id: 100,
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
reply_to_message_id: 100,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -3450,7 +3442,7 @@ describe("editMessageTelegram", () => {
|
||||
expect(botApi.editMessageText).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("edits Markdown text as Telegram rich HTML", async () => {
|
||||
it("edits text with formatted HTML", async () => {
|
||||
botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
|
||||
|
||||
await editMessageTelegram("123", 1, "**edited**", {
|
||||
@@ -3458,14 +3450,13 @@ describe("editMessageTelegram", () => {
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(botRawApi.editMessageText).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
message_id: 1,
|
||||
rich_message: { html: "<b>edited</b>" },
|
||||
expect(botApi.editMessageText).toHaveBeenCalledWith("123", 1, "<b>edited</b>", {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
expect(botRawApi.editMessageText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("edits complex Markdown text as Telegram rich HTML", async () => {
|
||||
it("edits complex text as formatted HTML", async () => {
|
||||
botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
|
||||
const markdown = ["## Updated", "", "- **bold**", "- _italic_", "", "`code`"].join("\n");
|
||||
|
||||
@@ -3474,14 +3465,19 @@ describe("editMessageTelegram", () => {
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(botRawApi.editMessageText).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
message_id: 1,
|
||||
rich_message: { html: markdownToTelegramRichHtml(markdown) },
|
||||
});
|
||||
expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
|
||||
const [chatId, messageId, sentText, sentOptions] =
|
||||
botApi.editMessageText.mock.calls.at(-1) ?? [];
|
||||
expect(chatId).toBe("123");
|
||||
expect(messageId).toBe(1);
|
||||
expect(String(sentText)).toContain("Updated");
|
||||
expect(String(sentText)).toContain("<b>bold</b>");
|
||||
expect(String(sentText)).toContain("<i>italic</i>");
|
||||
expect(sentOptions).toEqual({ parse_mode: "HTML" });
|
||||
expect(botRawApi.editMessageText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips rich entity detection for rich text edits when link previews are disabled", async () => {
|
||||
it("disables link previews for text edits", async () => {
|
||||
botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
|
||||
|
||||
await editMessageTelegram("123", 1, "https://example.com", {
|
||||
@@ -3490,15 +3486,16 @@ describe("editMessageTelegram", () => {
|
||||
linkPreview: false,
|
||||
});
|
||||
|
||||
expect(botRawApi.editMessageText).toHaveBeenCalledTimes(1);
|
||||
expect(botRawApi.editMessageText).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
message_id: 1,
|
||||
rich_message: {
|
||||
html: "https://example.com",
|
||||
skip_entity_detection: true,
|
||||
expect(botApi.editMessageText).toHaveBeenCalledWith(
|
||||
"123",
|
||||
1,
|
||||
'<a href="https://example.com">https://example.com</a>',
|
||||
{
|
||||
parse_mode: "HTML",
|
||||
link_preview_options: { is_disabled: true },
|
||||
},
|
||||
});
|
||||
);
|
||||
expect(botRawApi.editMessageText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+277
-60
@@ -3,6 +3,7 @@ import * as grammy from "grammy";
|
||||
import { type ApiClientOptions, Bot, HttpError } from "grammy";
|
||||
import type { ReactionType, ReactionTypeEmoji } from "grammy/types";
|
||||
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
|
||||
import type { MarkdownTableMode } 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,13 +22,15 @@ import type { TelegramInlineButtons } from "./button-types.js";
|
||||
import { splitTelegramCaption } from "./caption.js";
|
||||
import { asTelegramClientFetch, createTelegramClientFetch } from "./client-fetch.js";
|
||||
import { resolveTelegramTransport } from "./fetch.js";
|
||||
import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "./format.js";
|
||||
import {
|
||||
renderTelegramHtmlText,
|
||||
splitTelegramHtmlChunks,
|
||||
telegramHtmlToPlainTextFallback,
|
||||
} from "./format.js";
|
||||
import { buildInlineKeyboard } from "./inline-keyboard.js";
|
||||
import {
|
||||
isRecoverableTelegramNetworkError,
|
||||
isSafeToRetrySendError,
|
||||
isTelegramMessageHasNoTextError,
|
||||
isTelegramMessageNotModifiedError,
|
||||
isTelegramRateLimitError,
|
||||
isTelegramServerError,
|
||||
} from "./network-errors.js";
|
||||
@@ -74,7 +77,9 @@ export { buildInlineKeyboard } from "./inline-keyboard.js";
|
||||
|
||||
type TelegramApi = Bot["api"];
|
||||
export type TelegramApiOverride = Partial<TelegramApi>;
|
||||
type TelegramSendMessageParams = Parameters<TelegramApi["sendMessage"]>[2];
|
||||
type TelegramSendPollParams = Parameters<TelegramApi["sendPoll"]>[3];
|
||||
type TelegramEditMessageTextParams = Parameters<TelegramApi["editMessageText"]>[3];
|
||||
type TelegramEditMessageCaptionParams = Parameters<TelegramApi["editMessageCaption"]>[2];
|
||||
type TelegramCreateForumTopicParams = NonNullable<Parameters<TelegramApi["createForumTopic"]>[2]>;
|
||||
type TelegramThreadScopedParams = {
|
||||
@@ -97,6 +102,7 @@ type TelegramSendOpts = {
|
||||
api?: TelegramApiOverride;
|
||||
retry?: RetryConfig;
|
||||
textMode?: "markdown" | "html";
|
||||
tableMode?: MarkdownTableMode;
|
||||
/** 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. */
|
||||
@@ -173,6 +179,42 @@ function resolveTelegramMessageIdOrThrow(
|
||||
throw new Error(`Telegram ${context} returned no message_id`);
|
||||
}
|
||||
|
||||
function splitTelegramPlainTextChunks(text: string, limit: number): string[] {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
const normalizedLimit = Math.max(1, Math.floor(limit));
|
||||
const chunks: string[] = [];
|
||||
for (let start = 0; start < text.length; start += normalizedLimit) {
|
||||
chunks.push(text.slice(start, start + normalizedLimit));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function splitTelegramPlainTextFallback(text: string, chunkCount: number, limit: number): string[] {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
const normalizedLimit = Math.max(1, Math.floor(limit));
|
||||
const fixedChunks = splitTelegramPlainTextChunks(text, normalizedLimit);
|
||||
if (chunkCount <= 1 || fixedChunks.length >= chunkCount) {
|
||||
return fixedChunks;
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
let offset = 0;
|
||||
for (let index = 0; index < chunkCount; index += 1) {
|
||||
const remainingChars = text.length - offset;
|
||||
const remainingChunks = chunkCount - index;
|
||||
const nextChunkLength =
|
||||
remainingChunks === 1
|
||||
? remainingChars
|
||||
: Math.min(normalizedLimit, Math.ceil(remainingChars / remainingChunks));
|
||||
chunks.push(text.slice(offset, offset + nextChunkLength));
|
||||
offset += nextChunkLength;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function logTelegramOutboundSendOk(params: TelegramOutboundSuccessLogParams): void {
|
||||
const parts = [
|
||||
"telegram outbound send ok",
|
||||
@@ -200,6 +242,9 @@ function logTelegramOutboundSendOk(params: TelegramOutboundSuccessLogParams): vo
|
||||
}
|
||||
|
||||
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
||||
const MESSAGE_NOT_MODIFIED_RE =
|
||||
/400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i;
|
||||
const MESSAGE_HAS_NO_TEXT_RE = /400:\s*Bad Request:\s*there is no text in the message to edit/i;
|
||||
const MESSAGE_DELETE_NOOP_RE =
|
||||
/message to delete not found|message can't be deleted|MESSAGE_ID_INVALID|MESSAGE_DELETE_FORBIDDEN/i;
|
||||
const CHAT_NOT_FOUND_RE = /400: Bad Request: chat not found/i;
|
||||
@@ -389,6 +434,14 @@ function normalizeMessageId(raw: string | number): number {
|
||||
throw new Error("Message id is required for Telegram actions");
|
||||
}
|
||||
|
||||
function isTelegramMessageNotModifiedError(err: unknown): boolean {
|
||||
return MESSAGE_NOT_MODIFIED_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
function isTelegramMessageHasNoTextError(err: unknown): boolean {
|
||||
return MESSAGE_HAS_NO_TEXT_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
function isTelegramMessageDeleteNoopError(err: unknown): boolean {
|
||||
return MESSAGE_DELETE_NOOP_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
@@ -601,47 +654,74 @@ export async function sendMessageTelegram(
|
||||
});
|
||||
|
||||
const textMode = opts.textMode ?? "markdown";
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
supportsBlockTables: true,
|
||||
});
|
||||
const richMessageOptions = {
|
||||
skipEntityDetection: account.config.linkPreview === false,
|
||||
tableMode,
|
||||
};
|
||||
const useRichMessages = account.config.richMessages === true;
|
||||
const tableMode =
|
||||
opts.tableMode ??
|
||||
resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
supportsBlockTables: useRichMessages,
|
||||
});
|
||||
const renderHtmlText = (value: string) => renderTelegramHtmlText(value, { textMode, tableMode });
|
||||
const textLimit = Math.min(
|
||||
resolveTextChunkLimit(cfg, "telegram", account.accountId, {
|
||||
fallbackLimit: TELEGRAM_RICH_TEXT_LIMIT,
|
||||
}),
|
||||
TELEGRAM_RICH_TEXT_LIMIT,
|
||||
);
|
||||
const chunkMode = resolveChunkMode(cfg, "telegram", account.accountId);
|
||||
// Resolve link preview setting from config (default: enabled).
|
||||
const linkPreviewEnabled = account.config.linkPreview ?? true;
|
||||
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
|
||||
|
||||
type TelegramTextChunk = {
|
||||
plainText: string;
|
||||
htmlText?: string;
|
||||
};
|
||||
|
||||
const sendTelegramTextChunk = async (
|
||||
chunk: TelegramRichTextChunk,
|
||||
params?: TelegramRichMessageContextParams,
|
||||
chunk: TelegramTextChunk,
|
||||
params?: TelegramSendMessageParams,
|
||||
) => {
|
||||
const richRawApi = getTelegramRichRawApi(api);
|
||||
const richParams = {
|
||||
...params,
|
||||
const baseParams = params ? { ...params } : {};
|
||||
if (linkPreviewOptions) {
|
||||
baseParams.link_preview_options = linkPreviewOptions;
|
||||
}
|
||||
const plainParams: TelegramSendMessageParams = {
|
||||
...baseParams,
|
||||
...(opts.silent === true ? { disable_notification: true } : {}),
|
||||
};
|
||||
const result = await requestWithChatNotFound(
|
||||
() =>
|
||||
richRawApi.sendRichMessage({
|
||||
chat_id: chatId,
|
||||
rich_message: buildTelegramRichMessage(chunk.text, chunk.textMode, richMessageOptions),
|
||||
...richParams,
|
||||
}),
|
||||
"richMessage",
|
||||
);
|
||||
const hasPlainParams = Object.keys(plainParams).length > 0;
|
||||
const requestPlain = (label: string) =>
|
||||
requestWithChatNotFound(
|
||||
() =>
|
||||
hasPlainParams
|
||||
? api.sendMessage(chatId, chunk.plainText, plainParams)
|
||||
: api.sendMessage(chatId, chunk.plainText),
|
||||
label,
|
||||
);
|
||||
const result = !chunk.htmlText
|
||||
? await requestPlain("message")
|
||||
: await withTelegramHtmlParseFallback({
|
||||
label: "message",
|
||||
verbose: opts.verbose,
|
||||
requestHtml: (label) =>
|
||||
requestWithChatNotFound(
|
||||
() =>
|
||||
api.sendMessage(chatId, chunk.htmlText ?? chunk.plainText, {
|
||||
parse_mode: "HTML" as const,
|
||||
...plainParams,
|
||||
}),
|
||||
label,
|
||||
),
|
||||
requestPlain,
|
||||
});
|
||||
return { result, acceptedParams: params };
|
||||
};
|
||||
|
||||
const buildTextParams = (isLastChunk: boolean) =>
|
||||
hasThreadParams || (isLastChunk && replyMarkup)
|
||||
? {
|
||||
...threadParams,
|
||||
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const buildRichTextParams = (isLastChunk: boolean) =>
|
||||
hasRichThreadParams || (isLastChunk && replyMarkup)
|
||||
? {
|
||||
...richThreadParams,
|
||||
@@ -650,7 +730,7 @@ export async function sendMessageTelegram(
|
||||
: undefined;
|
||||
|
||||
const sendTelegramTextChunks = async (
|
||||
chunks: TelegramRichTextChunk[],
|
||||
chunks: TelegramTextChunk[],
|
||||
context: string,
|
||||
): Promise<{ messageId: string; chatId: string }> => {
|
||||
let lastMessageId = "";
|
||||
@@ -689,7 +769,7 @@ export async function sendMessageTelegram(
|
||||
accountId: account.accountId,
|
||||
chatId: lastChatId,
|
||||
messageId: lastMessageId,
|
||||
operation: "sendRichMessage",
|
||||
operation: "sendMessage",
|
||||
deliveryKind: "text",
|
||||
messageThreadId: lastAcceptedParams?.message_thread_id,
|
||||
replyToMessageId: opts.replyToMessageId,
|
||||
@@ -700,19 +780,117 @@ export async function sendMessageTelegram(
|
||||
return { messageId: lastMessageId, chatId: lastChatId };
|
||||
};
|
||||
|
||||
const buildChunkedTextPlan = (rawText: string): TelegramRichTextChunk[] => {
|
||||
const buildChunkedTextPlan = (rawText: string, context: string): TelegramTextChunk[] => {
|
||||
const htmlText = renderHtmlText(rawText);
|
||||
const fallbackText = textMode === "html" ? telegramHtmlToPlainTextFallback(htmlText) : rawText;
|
||||
let htmlChunks: string[];
|
||||
try {
|
||||
htmlChunks = splitTelegramHtmlChunks(htmlText, 4000);
|
||||
} catch (error) {
|
||||
logVerbose(
|
||||
`telegram ${context} failed HTML chunk planning, retrying as plain text: ${formatErrorMessage(
|
||||
error,
|
||||
)}`,
|
||||
);
|
||||
return splitTelegramPlainTextChunks(fallbackText, 4000).map((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 }));
|
||||
}
|
||||
const plainTextChunks = splitTelegramPlainTextFallback(fallbackText, htmlChunks.length, 4000);
|
||||
return htmlChunks.map((htmlTextLocal, index) => ({
|
||||
htmlText: htmlTextLocal,
|
||||
plainText: plainTextChunks[index] ?? htmlTextLocal,
|
||||
}));
|
||||
};
|
||||
|
||||
const sendChunkedText = async (rawText: string, context: string) =>
|
||||
useRichMessages
|
||||
? await sendTelegramRichTextChunks(buildRichTextPlan(rawText), context)
|
||||
: await sendTelegramTextChunks(buildChunkedTextPlan(rawText, context), context);
|
||||
|
||||
const buildRichTextPlan = (rawText: string): TelegramRichTextChunk[] => {
|
||||
const textLimit = Math.min(
|
||||
resolveTextChunkLimit(cfg, "telegram", account.accountId, {
|
||||
fallbackLimit: TELEGRAM_RICH_TEXT_LIMIT,
|
||||
}),
|
||||
TELEGRAM_RICH_TEXT_LIMIT,
|
||||
);
|
||||
return splitTelegramRichMessageTextChunks({
|
||||
text: rawText,
|
||||
textLimit,
|
||||
textMode,
|
||||
chunkMode,
|
||||
chunkMode: resolveChunkMode(cfg, "telegram", account.accountId),
|
||||
tableMode,
|
||||
skipEntityDetection: richMessageOptions.skipEntityDetection,
|
||||
skipEntityDetection: account.config.linkPreview === false,
|
||||
});
|
||||
};
|
||||
|
||||
const sendChunkedText = async (rawText: string, context: string) =>
|
||||
await sendTelegramTextChunks(buildChunkedTextPlan(rawText), context);
|
||||
const sendTelegramRichTextChunks = async (
|
||||
chunks: TelegramRichTextChunk[],
|
||||
context: string,
|
||||
): Promise<{ messageId: string; chatId: string }> => {
|
||||
const richRawApi = getTelegramRichRawApi(api);
|
||||
let lastMessageId = "";
|
||||
let lastChatId = chatId;
|
||||
let lastAcceptedParams: TelegramRichMessageContextParams | undefined;
|
||||
let sentChunkCount = 0;
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
const chunk = chunks[index];
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
const acceptedParams = buildRichTextParams(index === chunks.length - 1);
|
||||
const result = await requestWithChatNotFound(
|
||||
() =>
|
||||
richRawApi.sendRichMessage({
|
||||
chat_id: chatId,
|
||||
rich_message: buildTelegramRichMessage(chunk.text, chunk.textMode, {
|
||||
skipEntityDetection: account.config.linkPreview === false,
|
||||
tableMode,
|
||||
}),
|
||||
...acceptedParams,
|
||||
...(opts.silent === true ? { disable_notification: true } : {}),
|
||||
}),
|
||||
"richMessage",
|
||||
);
|
||||
const messageId = resolveTelegramMessageIdOrThrow(result, context);
|
||||
recordSentMessage(chatId, messageId, cfg);
|
||||
await recordOutboundMessageForPromptContext({
|
||||
cfg,
|
||||
account,
|
||||
chatId,
|
||||
message: result,
|
||||
messageId,
|
||||
text: chunk.plainText,
|
||||
...(acceptedParams?.message_thread_id !== undefined
|
||||
? { messageThreadId: acceptedParams.message_thread_id }
|
||||
: {}),
|
||||
});
|
||||
lastMessageId = String(messageId);
|
||||
lastChatId = String(result?.chat?.id ?? chatId);
|
||||
lastAcceptedParams = acceptedParams;
|
||||
sentChunkCount += 1;
|
||||
}
|
||||
if (lastMessageId) {
|
||||
logTelegramOutboundSendOk({
|
||||
accountId: account.accountId,
|
||||
chatId: lastChatId,
|
||||
messageId: lastMessageId,
|
||||
operation: "sendRichMessage",
|
||||
deliveryKind: "text",
|
||||
messageThreadId: lastAcceptedParams?.message_thread_id,
|
||||
replyToMessageId: opts.replyToMessageId,
|
||||
silent: opts.silent,
|
||||
chunkCount: sentChunkCount,
|
||||
});
|
||||
}
|
||||
return { messageId: lastMessageId, chatId: lastChatId };
|
||||
};
|
||||
|
||||
async function shouldSendTelegramImageAsPhoto(buffer: Buffer): Promise<boolean> {
|
||||
try {
|
||||
@@ -1350,19 +1528,22 @@ export async function editMessageTelegram(
|
||||
) => requestWithDiag(fn, label, shouldLog ? { shouldLog } : undefined);
|
||||
|
||||
const textMode = opts.textMode ?? "markdown";
|
||||
const useRichMessages = account.config.richMessages === true;
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
supportsBlockTables: true,
|
||||
supportsBlockTables: useRichMessages,
|
||||
});
|
||||
const htmlText = renderTelegramHtmlText(text, { textMode, tableMode });
|
||||
const plainText = textMode === "html" ? telegramHtmlToPlainTextFallback(htmlText) : text;
|
||||
const richRawApi = getTelegramRichRawApi(api);
|
||||
const richMessage = buildTelegramRichMessage(text, textMode, {
|
||||
skipEntityDetection: opts.linkPreview === false,
|
||||
tableMode,
|
||||
});
|
||||
const richRawApi = useRichMessages ? getTelegramRichRawApi(api) : undefined;
|
||||
const richMessage = useRichMessages
|
||||
? buildTelegramRichMessage(text, textMode, {
|
||||
skipEntityDetection: opts.linkPreview === false,
|
||||
tableMode,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// Reply markup semantics:
|
||||
// - buttons === undefined → don't send reply_markup (keep existing)
|
||||
@@ -1372,10 +1553,22 @@ export async function editMessageTelegram(
|
||||
const builtKeyboard = shouldTouchButtons ? buildInlineKeyboard(opts.buttons) : undefined;
|
||||
const replyMarkup = shouldTouchButtons ? (builtKeyboard ?? { inline_keyboard: [] }) : undefined;
|
||||
|
||||
const textEditParams: Pick<TelegramEditRichMessageTextParams, "reply_markup"> = {};
|
||||
const textEditParams: TelegramEditMessageTextParams = {
|
||||
parse_mode: "HTML",
|
||||
};
|
||||
if (opts.linkPreview === false) {
|
||||
textEditParams.link_preview_options = { is_disabled: true };
|
||||
}
|
||||
if (replyMarkup !== undefined) {
|
||||
textEditParams.reply_markup = replyMarkup;
|
||||
}
|
||||
const plainTextParams: TelegramEditMessageTextParams = {};
|
||||
if (opts.linkPreview === false) {
|
||||
plainTextParams.link_preview_options = { is_disabled: true };
|
||||
}
|
||||
if (replyMarkup !== undefined) {
|
||||
plainTextParams.reply_markup = replyMarkup;
|
||||
}
|
||||
const captionEditParams: TelegramEditMessageCaptionParams = {
|
||||
caption: htmlText,
|
||||
parse_mode: "HTML",
|
||||
@@ -1390,18 +1583,42 @@ export async function editMessageTelegram(
|
||||
plainCaptionParams.reply_markup = replyMarkup;
|
||||
}
|
||||
|
||||
const performTextEdit = () =>
|
||||
requestWithEditShouldLog(
|
||||
() =>
|
||||
richRawApi.editMessageText({
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
rich_message: richMessage,
|
||||
...textEditParams,
|
||||
}),
|
||||
"editMessage",
|
||||
(err) => !isTelegramMessageNotModifiedError(err),
|
||||
);
|
||||
const performTextEdit = () => {
|
||||
if (richRawApi && richMessage) {
|
||||
const richEditParams: Pick<TelegramEditRichMessageTextParams, "reply_markup"> =
|
||||
replyMarkup === undefined ? {} : { reply_markup: replyMarkup };
|
||||
return requestWithEditShouldLog(
|
||||
() =>
|
||||
richRawApi.editMessageText({
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
rich_message: richMessage,
|
||||
...richEditParams,
|
||||
}),
|
||||
"editMessage",
|
||||
(err) => !isTelegramMessageNotModifiedError(err),
|
||||
);
|
||||
}
|
||||
return withTelegramHtmlParseFallback({
|
||||
label: "editMessage",
|
||||
verbose: opts.verbose,
|
||||
requestHtml: (retryLabel) =>
|
||||
requestWithEditShouldLog(
|
||||
() => api.editMessageText(chatId, messageId, htmlText, textEditParams),
|
||||
retryLabel,
|
||||
(err) => !isTelegramMessageNotModifiedError(err),
|
||||
),
|
||||
requestPlain: (retryLabel) =>
|
||||
requestWithEditShouldLog(
|
||||
() =>
|
||||
Object.keys(plainTextParams).length > 0
|
||||
? api.editMessageText(chatId, messageId, plainText, plainTextParams)
|
||||
: api.editMessageText(chatId, messageId, plainText),
|
||||
retryLabel,
|
||||
(plainErr) => !isTelegramMessageNotModifiedError(plainErr),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const performCaptionEdit = () =>
|
||||
withTelegramHtmlParseFallback({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Telegram tests cover telegram outbound plugin behavior.
|
||||
import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-chunking";
|
||||
// Telegram tests cover telegram outbound plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { splitTelegramHtmlChunks } from "./format.js";
|
||||
import { telegramOutbound } from "./outbound-adapter.js";
|
||||
@@ -19,13 +19,13 @@ describe("telegramPlugin outbound", () => {
|
||||
it("uses static outbound contract when Telegram runtime is uninitialized", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = `${"hello\n".repeat(1200)}tail`;
|
||||
const expected = chunkMarkdownTextWithMode(text, 32_768, "length");
|
||||
const expected = chunkMarkdownTextWithMode(text, 4000, "length");
|
||||
|
||||
expect(telegramOutbound.chunker?.(text, 32_768)).toEqual(expected);
|
||||
expect(telegramOutbound.chunker?.(text, 4000)).toEqual(expected);
|
||||
expect(telegramOutbound.deliveryMode).toBe("direct");
|
||||
expect(telegramOutbound.chunkerMode).toBe("markdown");
|
||||
expect(telegramOutbound.chunkedTextFormatting).toBeUndefined();
|
||||
expect(telegramOutbound.textChunkLimit).toBe(32_768);
|
||||
expect(telegramOutbound.textChunkLimit).toBe(4000);
|
||||
expect(telegramOutbound.presentationCapabilities?.limits?.text?.markdownDialect).toBe(
|
||||
"markdown",
|
||||
);
|
||||
@@ -43,7 +43,7 @@ describe("telegramPlugin outbound", () => {
|
||||
expect(telegramOutbound.chunker?.(text, 4000)).toEqual([text]);
|
||||
});
|
||||
|
||||
it("keeps markdown tables intact for rich message parsing", () => {
|
||||
it("preserves markdown tables for the configured delivery renderer", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = ["| Name | Value |", "|------|-------|", "| A | 1 |"].join("\n");
|
||||
|
||||
@@ -54,63 +54,66 @@ describe("telegramPlugin outbound", () => {
|
||||
expect(chunks).toEqual([text]);
|
||||
});
|
||||
|
||||
it("keeps wide markdown tables for rich HTML rendering", () => {
|
||||
it("keeps wide markdown tables as visible text in the HTML text path", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = markdownTable(21);
|
||||
|
||||
const chunks = telegramOutbound.chunker?.(text, 32_768);
|
||||
const chunks = telegramOutbound.chunker?.(text, 4000);
|
||||
|
||||
expect(chunks).toEqual([text]);
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks?.[0]).toContain("| H21 |");
|
||||
expect(chunks?.[0]).toContain("| 1 | 2 | 3 |");
|
||||
});
|
||||
|
||||
it("keeps fenced and unfenced wide markdown tables for rich HTML rendering", () => {
|
||||
it("preserves both fenced and unfenced wide tables as visible text", () => {
|
||||
clearTelegramRuntime();
|
||||
const fencedTable = markdownTable(25);
|
||||
const outsideTable = markdownTable(21);
|
||||
const text = ["Before", "~~~", fencedTable, "~~~", "After", outsideTable].join("\n");
|
||||
|
||||
const chunks = telegramOutbound.chunker?.(text, 32_768);
|
||||
const chunks = telegramOutbound.chunker?.(text, 4000);
|
||||
|
||||
expect(chunks).toEqual([text]);
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks?.[0]).toContain("Before");
|
||||
expect(chunks?.[0]).toContain("After");
|
||||
expect(chunks?.[0]).toContain(fencedTable);
|
||||
expect(chunks?.[0]).toContain(outsideTable);
|
||||
});
|
||||
|
||||
it("chunks rich markdown by Telegram's block limit", () => {
|
||||
it("chunks long markdown paragraphs by the Telegram text-message limit", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = Array.from({ length: 900 }, (_, index) => `Paragraph ${index + 1}`).join("\n\n");
|
||||
|
||||
const chunks = telegramOutbound.chunker?.(text, 32_768);
|
||||
const chunks = telegramOutbound.chunker?.(text, 4000);
|
||||
|
||||
expect(chunks).toHaveLength(2);
|
||||
expect(
|
||||
chunks?.every(
|
||||
(chunk) => chunk.split(/\n[\t ]*\n+/).filter((block) => block.trim()).length <= 500,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(chunks?.join("\n\n")).toBe(text);
|
||||
expect((chunks?.length ?? 0) > 1).toBe(true);
|
||||
expect(chunks?.every((chunk) => chunk.length <= 4000)).toBe(true);
|
||||
expect(chunks?.join("")).toContain("Paragraph 900");
|
||||
});
|
||||
|
||||
it("chunks rich markdown headings by Telegram's block limit", () => {
|
||||
it("chunks long markdown headings by the Telegram text-message limit", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = Array.from({ length: 600 }, (_, index) => `# Heading ${index + 1}`).join("\n");
|
||||
|
||||
const chunks = telegramOutbound.chunker?.(text, 32_768);
|
||||
const chunks = telegramOutbound.chunker?.(text, 4000);
|
||||
|
||||
expect(chunks).toHaveLength(2);
|
||||
expect(chunks?.at(0)?.match(/^# /gm)).toHaveLength(500);
|
||||
expect(chunks?.at(1)?.match(/^# /gm)).toHaveLength(100);
|
||||
expect(chunks?.join("\n")).toBe(text);
|
||||
expect((chunks?.length ?? 0) > 1).toBe(true);
|
||||
expect(chunks?.every((chunk) => chunk.length <= 4000)).toBe(true);
|
||||
expect(chunks?.join("")).toContain("Heading 600");
|
||||
});
|
||||
|
||||
it("keeps long rich markdown lists intact", () => {
|
||||
it("chunks long markdown lists by the Telegram text-message limit", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = Array.from({ length: 600 }, (_, index) => `- Item ${index + 1}`).join("\n");
|
||||
|
||||
const chunks = telegramOutbound.chunker?.(text, 32_768);
|
||||
const chunks = telegramOutbound.chunker?.(text, 4000);
|
||||
|
||||
expect(chunks).toEqual([text]);
|
||||
expect((chunks?.length ?? 0) > 1).toBe(true);
|
||||
expect(chunks?.every((chunk) => chunk.length <= 4000)).toBe(true);
|
||||
expect(chunks?.join("")).toContain("Item 600");
|
||||
});
|
||||
|
||||
it("keeps tall rich markdown tables intact", () => {
|
||||
it("chunks tall markdown tables by the Telegram text-message limit", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = [
|
||||
"| Name | Value |",
|
||||
@@ -118,8 +121,10 @@ describe("telegramPlugin outbound", () => {
|
||||
...Array.from({ length: 600 }, (_, index) => `| Row ${index + 1} | ${index + 1} |`),
|
||||
].join("\n");
|
||||
|
||||
const chunks = telegramOutbound.chunker?.(text, 32_768);
|
||||
const chunks = telegramOutbound.chunker?.(text, 4000);
|
||||
|
||||
expect(chunks).toEqual([text]);
|
||||
expect((chunks?.length ?? 0) > 1).toBe(true);
|
||||
expect(chunks?.every((chunk) => chunk.length <= 4000)).toBe(true);
|
||||
expect(chunks?.join("")).toContain("Row 600");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -165,6 +165,11 @@ export type TelegramAccountConfig = {
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/**
|
||||
* Use Telegram Bot API 10.1 rich messages for text sends and edits.
|
||||
* Default: false until Telegram clients render rich messages consistently.
|
||||
*/
|
||||
richMessages?: boolean;
|
||||
/** Streaming + chunking settings. Prefer this nested shape over legacy flat keys. */
|
||||
streaming?: TelegramPreviewStreamingConfig;
|
||||
mediaMaxMb?: number;
|
||||
|
||||
@@ -281,6 +281,7 @@ export const TelegramAccountSchemaBase = z
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
direct: z.record(z.string(), TelegramDirectSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
richMessages: z.boolean().optional(),
|
||||
streaming: TelegramPreviewStreamingConfigSchema.optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
|
||||
Reference in New Issue
Block a user