Compare commits

...

1 Commits

Author SHA1 Message Date
Mason Huang
35dedb8e40 fix(telegram): avoid rich messages in group chats 2026-06-16 06:58:16 +08:00
7 changed files with 573 additions and 41 deletions

View File

@@ -31,7 +31,11 @@ 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 {
renderTelegramHtmlText,
splitTelegramHtmlChunks,
telegramHtmlToPlainTextFallback,
} from "../format.js";
import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js";
import {
splitTelegramRichMessageTextChunks,
@@ -56,6 +60,7 @@ import {
const VOICE_FORBIDDEN_MARKER = "VOICE_MESSAGES_FORBIDDEN";
const CAPTION_TOO_LONG_RE = /caption is too long/i;
const TELEGRAM_LEGACY_TEXT_LIMIT = 4096;
const GrammyErrorCtor: typeof GrammyError | undefined =
typeof GrammyError === "function" ? GrammyError : undefined;
@@ -82,8 +87,19 @@ function buildChunkTextResolver(params: {
chunkMode: ChunkMode;
tableMode?: MarkdownTableMode;
skipEntityDetection?: boolean;
chatType: "direct" | "group";
}): ChunkTextFn {
return (markdown: string) => {
if (params.chatType === "group") {
return splitTelegramHtmlChunks(
renderTelegramHtmlText(markdown, { tableMode: params.tableMode }),
Math.min(params.textLimit, TELEGRAM_LEGACY_TEXT_LIMIT),
).map((text) => ({
text,
textMode: "html",
plainText: telegramHtmlToPlainTextFallback(text),
}));
}
return splitTelegramRichMessageTextChunks({
text: markdown,
textLimit: params.textLimit,
@@ -95,6 +111,23 @@ function buildChunkTextResolver(params: {
};
}
function resolveReplyChatType(params: {
chatId: string;
thread?: TelegramThreadSpec | null;
isGroup?: boolean;
}) {
if (params.isGroup === true) {
return "group";
}
if (params.thread?.scope === "dm") {
return "direct";
}
if (params.thread) {
return "group";
}
return params.chatId.trim().startsWith("-") ? "group" : "direct";
}
function markDelivered(progress: DeliveryProgress): void {
progress.hasDelivered = true;
progress.deliveredCount += 1;
@@ -161,6 +194,7 @@ async function deliverTextReply(params: {
linkPreview?: boolean;
silent?: boolean;
tableMode?: MarkdownTableMode;
chatType?: "direct" | "group";
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
@@ -193,6 +227,7 @@ async function deliverTextReply(params: {
tableMode: params.tableMode,
silent: params.silent,
replyMarkup,
chatType: params.chatType,
},
);
if (firstDeliveredMessageId == null) {
@@ -214,6 +249,7 @@ async function sendPendingFollowUpText(params: {
linkPreview?: boolean;
silent?: boolean;
tableMode?: MarkdownTableMode;
chatType?: "direct" | "group";
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
@@ -235,6 +271,7 @@ async function sendPendingFollowUpText(params: {
tableMode: params.tableMode,
silent: params.silent,
replyMarkup,
chatType: params.chatType,
});
},
});
@@ -280,6 +317,7 @@ async function sendTelegramVoiceFallbackText(opts: {
tableMode?: MarkdownTableMode;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
replyQuoteText?: string;
chatType?: "direct" | "group";
}): Promise<number | undefined> {
let firstDeliveredMessageId: number | undefined;
const chunks = filterEmptyTelegramTextChunks(opts.chunkText(opts.text));
@@ -300,6 +338,7 @@ async function sendTelegramVoiceFallbackText(opts: {
tableMode: opts.tableMode,
silent: opts.silent,
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
chatType: opts.chatType,
});
if (firstDeliveredMessageId == null) {
firstDeliveredMessageId = messageId;
@@ -334,6 +373,7 @@ async function deliverMediaReply(params: {
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
chatType?: "direct" | "group";
}): Promise<{ firstDeliveredMessageId?: number; visibleFallbackText?: string }> {
let firstDeliveredMessageId: number | undefined;
let visibleFallbackText: string | undefined;
@@ -484,6 +524,7 @@ async function deliverMediaReply(params: {
silent: params.silent,
replyMarkup: params.replyMarkup,
replyQuoteText: params.replyQuoteText,
chatType: params.chatType,
});
if (firstDeliveredMessageId == null) {
firstDeliveredMessageId = fallbackMessageId;
@@ -514,6 +555,7 @@ async function deliverMediaReply(params: {
linkPreview: params.linkPreview,
silent: params.silent,
replyMarkup: params.replyMarkup,
chatType: params.chatType,
});
visibleFallbackText = fallbackText;
}
@@ -563,6 +605,7 @@ async function deliverMediaReply(params: {
linkPreview: params.linkPreview,
silent: params.silent,
tableMode: params.tableMode,
chatType: params.chatType,
replyToId: params.replyToId,
replyToMode: params.replyToMode,
progress: params.progress,
@@ -722,6 +765,11 @@ export async function deliverReplies(params: {
const transcriptMirror = params.transcriptMirror;
const deliveredContents: Array<{ text: string; mediaUrls: string[] }> = [];
const hookRunner = getGlobalHookRunner();
const replyChatType = resolveReplyChatType({
chatId: params.chatId,
thread: params.thread,
isGroup: params.mirrorIsGroup,
});
const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false;
const hasMessageSentHooks = hookRunner?.hasHooks("message_sent") ?? false;
const chunkText = buildChunkTextResolver({
@@ -729,6 +777,7 @@ export async function deliverReplies(params: {
chunkMode: params.chunkMode ?? "length",
tableMode: params.tableMode,
skipEntityDetection: params.linkPreview === false,
chatType: replyChatType,
});
const candidateReplies: ReplyPayload[] = [];
for (const reply of params.replies) {
@@ -832,6 +881,9 @@ export async function deliverReplies(params: {
presentation,
interactive,
}),
{
chatType: replyChatType,
},
);
let firstDeliveredMessageId: number | undefined;
if (mediaList.length === 0) {
@@ -850,6 +902,7 @@ export async function deliverReplies(params: {
linkPreview: params.linkPreview,
silent: params.silent,
tableMode: params.tableMode,
chatType: replyChatType,
replyToId,
replyToMode: params.replyToMode,
progress,
@@ -870,6 +923,7 @@ export async function deliverReplies(params: {
onVoiceRecording: params.onVoiceRecording,
linkPreview: params.linkPreview,
silent: params.silent,
chatType: replyChatType,
replyQuoteMessageId: replyQuote.messageId,
replyQuoteText: replyQuote.text,
replyQuotePosition: replyQuote.position,

View File

@@ -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 { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "../format.js";
import { isSafeToRetrySendError, isTelegramRateLimitError } from "../network-errors.js";
import {
buildTelegramSendParams,
@@ -23,6 +24,7 @@ import type { TelegramThreadSpec } from "./helpers.js";
export { buildTelegramSendParams } from "../reply-parameters.js";
const QUOTE_PARAM_RE = /\bquote not found\b|\bQUOTE_TEXT_INVALID\b|\bquote text invalid\b/i;
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
const GrammyErrorCtor: typeof GrammyError | undefined =
typeof GrammyError === "function" ? GrammyError : undefined;
@@ -33,6 +35,13 @@ function isTelegramQuoteParamError(err: unknown): boolean {
return QUOTE_PARAM_RE.test(formatErrorMessage(err));
}
function isTelegramHtmlParseError(err: unknown): boolean {
if (GrammyErrorCtor && err instanceof GrammyErrorCtor) {
return PARSE_ERR_RE.test(err.description);
}
return PARSE_ERR_RE.test(formatErrorMessage(err));
}
function createTelegramDeliverySendRetry() {
return createTelegramRetryRunner({
shouldRetry: (err) => isSafeToRetrySendError(err) || isTelegramRateLimitError(err),
@@ -104,6 +113,7 @@ export async function sendTelegramText(
tableMode?: MarkdownTableMode;
silent?: boolean;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
chatType?: "direct" | "group";
},
): Promise<number> {
const baseParams = buildTelegramSendParams({
@@ -117,15 +127,70 @@ export async function sendTelegramText(
});
const richParams = toTelegramRichMessageContextParams(baseParams);
const textMode = opts?.textMode ?? "markdown";
const normalizedChatId = chatId.trim();
const shouldUseRichText =
opts?.chatType !== "group" &&
(opts?.thread?.scope === "dm" || (!opts?.thread && !normalizedChatId.startsWith("-")));
if (!text.trim()) {
throw new Error("Message must be non-empty for Telegram sends");
}
if (!shouldUseRichText) {
const htmlText = renderTelegramHtmlText(text, {
textMode,
tableMode: opts?.tableMode,
});
const htmlParams: Record<string, unknown> = {
parse_mode: "HTML",
...(opts?.linkPreview === false ? { link_preview_options: { is_disabled: true } } : {}),
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
...baseParams,
};
const plainParams = { ...htmlParams };
delete plainParams.parse_mode;
const sendLegacy = async (
operation: string,
body: string,
requestParams: Record<string, unknown>,
) =>
await sendTelegramWithThreadFallback({
operation,
runtime,
thread: opts?.thread,
requestParams,
send: (effectiveParams) =>
bot.api.sendMessage(chatId, body, {
...effectiveParams,
}),
});
let res: Awaited<ReturnType<typeof sendLegacy>>;
try {
res = await sendLegacy("sendMessage", htmlText, htmlParams);
} catch (err) {
if (!isTelegramHtmlParseError(err)) {
throw err;
}
runtime.log?.(
`telegram sendMessage failed with HTML parse error; retrying as plain text: ${formatErrorMessage(
err,
)}`,
);
res = await sendLegacy(
"sendMessage (plain)",
telegramHtmlToPlainTextFallback(htmlText),
plainParams,
);
}
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id}`);
return res.message_id;
}
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");
}
const res = await sendTelegramWithThreadFallback({
operation: "sendRichMessage",
runtime,

View File

@@ -166,6 +166,10 @@ function firstSendText(mock: ReturnType<typeof vi.fn>) {
return text as string;
}
function rawSendRichMessageMock(bot: Bot): ReturnType<typeof vi.fn> {
return (bot.api.raw as unknown as { sendRichMessage: ReturnType<typeof vi.fn> }).sendRichMessage;
}
function createSendMessageHarness(messageId = 4) {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
@@ -833,6 +837,98 @@ describe("deliverReplies", () => {
expectRecordFields(mockCallArg(sendMessage, 0, 2), { skip_entity_detection: true });
});
it("uses Bot API sendMessage instead of rich messages for group replies", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 63,
chat: { id: "-1001234567890" },
});
const bot = createBot({ sendMessage });
await deliverReplies({
...baseDeliveryParams,
chatId: "-1001234567890",
replies: [{ text: "hi Mason" }],
runtime,
bot,
thread: { id: 456, scope: "forum" },
});
expect(rawSendRichMessageMock(bot)).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith("-1001234567890", "hi Mason", {
parse_mode: "HTML",
message_thread_id: 456,
});
});
it("treats mirrored group replies as group sends even when Telegram uses a positive chat id", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 64,
chat: { id: "584667058" },
});
const bot = createBot({ sendMessage });
await deliverReplies({
...baseDeliveryParams,
chatId: "584667058",
mirrorIsGroup: true,
mirrorGroupId: "-5278454993",
replies: [
{
text: "hi Mason",
channelData: {
telegram: {
buttons: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
},
},
},
],
runtime,
bot,
thread: { scope: "none" },
});
expect(rawSendRichMessageMock(bot)).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith("584667058", "hi Mason", {
parse_mode: "HTML",
reply_markup: {
inline_keyboard: [[{ text: "UPDATE", url: "https://example.com/update" }]],
},
});
});
it("chunks mirrored group replies for the Bot API sendMessage limit", async () => {
const runtime = createRuntime();
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ message_id: 65, chat: { id: "584667058" } })
.mockResolvedValueOnce({ message_id: 66, chat: { id: "584667058" } });
const bot = createBot({ sendMessage });
const longText = "a".repeat(4100);
await deliverReplies({
...baseDeliveryParams,
chatId: "584667058",
mirrorIsGroup: true,
mirrorGroupId: "-5278454993",
replies: [{ text: longText }],
runtime,
bot,
thread: { scope: "none" },
textLimit: 100_000,
});
expect(rawSendRichMessageMock(bot)).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledTimes(2);
const firstText = firstSendText(sendMessage);
const secondText = mockCallArg(sendMessage, 1, 1);
expect(typeof secondText).toBe("string");
expect(firstText.length).toBeLessThanOrEqual(4096);
expect((secondText as string).length).toBeLessThanOrEqual(4096);
expect(`${firstText}${secondText as string}`).toBe(longText);
});
it("includes message_thread_id for DM topics", async () => {
const { runtime, sendMessage, bot } = createSendMessageHarness();

View File

@@ -2,8 +2,11 @@
import type { InlineKeyboardButton, InlineKeyboardMarkup } from "grammy/types";
import type { TelegramInlineButtons } from "./button-types.js";
export type TelegramInlineKeyboardChatType = "direct" | "group" | "unknown";
function toInlineKeyboardButton(
button: TelegramInlineButtons[number][number] | undefined,
chatType: TelegramInlineKeyboardChatType,
): InlineKeyboardButton | undefined {
if (!button?.text) {
return undefined;
@@ -19,6 +22,11 @@ function toInlineKeyboardButton(
: { text: button.text, callback_data: button.callback_data };
}
if (button.web_app?.url) {
if (chatType === "group") {
return button.style
? { text: button.text, url: button.web_app.url, style: button.style }
: { text: button.text, url: button.web_app.url };
}
return button.style
? { text: button.text, web_app: { url: button.web_app.url }, style: button.style }
: { text: button.text, web_app: { url: button.web_app.url } };
@@ -28,14 +36,16 @@ function toInlineKeyboardButton(
export function buildInlineKeyboard(
buttons?: TelegramInlineButtons,
options?: { chatType?: TelegramInlineKeyboardChatType },
): InlineKeyboardMarkup | undefined {
if (!buttons?.length) {
return undefined;
}
const chatType = options?.chatType ?? "unknown";
const rows = buttons
.map((row) =>
row
.map(toInlineKeyboardButton)
.map((button) => toInlineKeyboardButton(button, chatType))
.filter((button): button is InlineKeyboardButton => Boolean(button)),
)
.filter((row) => row.length > 0);

View File

@@ -53,6 +53,7 @@ const {
createForumTopicTelegram,
deleteMessageTelegram,
editForumTopicTelegram,
editMessageReplyMarkupTelegram,
editMessageTelegram,
pinMessageTelegram,
reactMessageTelegram,
@@ -1357,6 +1358,127 @@ describe("sendMessageTelegram", () => {
expect(chunks.join("")).toContain("<figure>");
});
it("uses Bot API sendMessage for group text so Telegram clients can render it", async () => {
botApi.sendMessage.mockResolvedValue({
message_id: 61,
chat: { id: "-1001234567890" },
});
await sendMessageTelegram("telegram:group:-1001234567890:topic:456", "hi Mason", {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
});
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
expect(botApi.sendMessage).toHaveBeenCalledWith("-1001234567890", "hi Mason", {
parse_mode: "HTML",
message_thread_id: 456,
});
});
it("falls back private-chat web app buttons to url buttons for group sends", async () => {
botApi.sendMessage.mockResolvedValue({
message_id: 61,
chat: { id: "-1001234567890" },
});
await sendMessageTelegram("telegram:group:-1001234567890:topic:456", "OpenClaw status", {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
buttons: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
});
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
expect(botApi.sendMessage).toHaveBeenCalledWith("-1001234567890", "OpenClaw status", {
parse_mode: "HTML",
message_thread_id: 456,
reply_markup: {
inline_keyboard: [[{ text: "UPDATE", url: "https://example.com/update" }]],
},
});
});
it("keeps private-chat web app buttons after resolving a nonnumeric target to a direct chat", async () => {
const getChat = vi.fn().mockResolvedValue({ id: 123 });
const sendRichMessage = vi.fn().mockResolvedValue({
message_id: 62,
chat: { id: "123" },
});
const sendMessage = vi.fn();
const api = {
getChat,
sendMessage,
raw: { sendRichMessage },
} as unknown as TelegramApiOverride;
await sendMessageTelegram("https://t.me/direct_user", "OpenClaw status", {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
api,
buttons: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
gatewayClientScopes: ["operator.write"],
});
expect(getChat).toHaveBeenCalledWith("@direct_user");
expect(sendMessage).not.toHaveBeenCalled();
expect(sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: { html: "OpenClaw status" },
reply_markup: {
inline_keyboard: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
},
});
});
it("keeps private-chat web app buttons for direct rich sends", async () => {
botApi.sendMessage.mockResolvedValue({
message_id: 62,
chat: { id: "123" },
});
await sendMessageTelegram("123", "OpenClaw status", {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
buttons: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
});
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
expect(richSendCallParams()[0]?.reply_markup).toEqual({
inline_keyboard: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
});
});
it("falls back private-chat web app buttons to url buttons after resolving a group target", async () => {
const getChat = vi.fn().mockResolvedValue({ id: -1001234567890 });
const sendRichMessage = vi.fn();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 63,
chat: { id: "-1001234567890" },
});
const api = {
getChat,
sendMessage,
raw: { sendRichMessage },
} as unknown as TelegramApiOverride;
await sendMessageTelegram("https://t.me/mychannel", "OpenClaw status", {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
api,
buttons: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
gatewayClientScopes: ["operator.write"],
});
expect(getChat).toHaveBeenCalledWith("@mychannel");
expect(sendRichMessage).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith("-1001234567890", "OpenClaw status", {
parse_mode: "HTML",
reply_markup: {
inline_keyboard: [[{ text: "UPDATE", url: "https://example.com/update" }]],
},
});
});
it("fails when Telegram text send returns no message_id", async () => {
const sendMessage = vi.fn().mockResolvedValue({
chat: { id: "123" },
@@ -2593,7 +2715,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");
@@ -3500,6 +3622,63 @@ describe("editMessageTelegram", () => {
},
});
});
it("falls back private-chat web app buttons to url buttons when editing resolved group text", async () => {
const api = {
getChat: vi.fn().mockResolvedValue({ id: -1001234567890 }),
raw: {
editMessageText: vi.fn().mockResolvedValue({ message_id: 1, chat: { id: -1001234567890 } }),
},
} as unknown as TelegramApiOverride;
await editMessageTelegram("https://t.me/mychannel", 1, "Updated", {
token: "tok",
cfg: TELEGRAM_TEST_CFG,
api,
buttons: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
gatewayClientScopes: ["operator.write"],
});
expect(api.getChat).toHaveBeenCalledWith("@mychannel");
expect(api.raw?.editMessageText).toHaveBeenCalledWith({
chat_id: "-1001234567890",
message_id: 1,
rich_message: { html: "Updated" },
reply_markup: {
inline_keyboard: [[{ text: "UPDATE", url: "https://example.com/update" }]],
},
});
});
});
describe("editMessageReplyMarkupTelegram", () => {
it("falls back private-chat web app buttons to url buttons when editing resolved group markup", async () => {
const api = {
getChat: vi.fn().mockResolvedValue({ id: -1001234567890 }),
editMessageReplyMarkup: vi
.fn()
.mockResolvedValue({ message_id: 1, chat: { id: -1001234567890 } }),
} as unknown as TelegramApiOverride;
await editMessageReplyMarkupTelegram(
"https://t.me/mychannel",
1,
[[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
{
token: "tok",
cfg: TELEGRAM_TEST_CFG,
api,
gatewayClientScopes: ["operator.write"],
},
);
expect(api.getChat).toHaveBeenCalledWith("@mychannel");
expect(api.editMessageReplyMarkup).toHaveBeenCalledWith("-1001234567890", 1, {
reply_markup: {
inline_keyboard: [[{ text: "UPDATE", url: "https://example.com/update" }]],
},
});
});
});
describe("sendPollTelegram", () => {

View File

@@ -21,7 +21,11 @@ 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,
@@ -44,7 +48,6 @@ import {
TELEGRAM_RICH_TEXT_LIMIT,
toTelegramRichMessageContextParams,
type TelegramEditRichMessageTextParams,
type TelegramRichMessageContextParams,
type TelegramRichTextChunk,
} from "./rich-message.js";
import {
@@ -203,6 +206,7 @@ const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity
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;
const TELEGRAM_LEGACY_TEXT_LIMIT = 4096;
const sendLogger = createSubsystemLogger("telegram/send");
const diagLogger = createSubsystemLogger("telegram/diagnostic");
const telegramClientOptionsCache = new Map<string, ApiClientOptions | undefined>();
@@ -573,13 +577,14 @@ export async function sendMessageTelegram(
const mediaMaxBytes =
opts.maxBytes ??
(typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb : 100) * 1024 * 1024;
const replyMarkup = buildInlineKeyboard(opts.buttons);
const resolvedChatType = parseTelegramTarget(chatId).chatType;
const replyMarkup = buildInlineKeyboard(opts.buttons, { chatType: resolvedChatType });
const threadParams = buildTelegramThreadReplyParams({
thread: resolveTelegramSendThreadSpec({
targetMessageThreadId: target.messageThreadId,
messageThreadId: opts.messageThreadId,
chatType: target.chatType,
chatType: resolvedChatType,
}),
replyToMessageId: opts.replyToMessageId,
replyQuoteText: opts.quoteText,
@@ -612,42 +617,94 @@ export async function sendMessageTelegram(
tableMode,
};
const renderHtmlText = (value: string) => renderTelegramHtmlText(value, { textMode, tableMode });
const useRichTextSend = resolvedChatType !== "group";
const textTransportLimit = useRichTextSend
? TELEGRAM_RICH_TEXT_LIMIT
: TELEGRAM_LEGACY_TEXT_LIMIT;
const textLimit = Math.min(
resolveTextChunkLimit(cfg, "telegram", account.accountId, {
fallbackLimit: TELEGRAM_RICH_TEXT_LIMIT,
fallbackLimit: textTransportLimit,
}),
TELEGRAM_RICH_TEXT_LIMIT,
textTransportLimit,
);
const chunkMode = resolveChunkMode(cfg, "telegram", account.accountId);
type TelegramTextParams = Record<string, unknown> & {
message_thread_id?: number;
reply_markup?: ReturnType<typeof buildInlineKeyboard>;
};
const sendTelegramTextChunk = async (
chunk: TelegramRichTextChunk,
params?: TelegramRichMessageContextParams,
params?: TelegramTextParams,
) => {
const richRawApi = getTelegramRichRawApi(api);
const richParams = {
...params,
if (useRichTextSend) {
const richRawApi = getTelegramRichRawApi(api);
const richParams = {
...params,
...(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",
);
return { result, acceptedParams: params, operation: "sendRichMessage" };
}
const legacyParams: Record<string, unknown> = {
parse_mode: "HTML",
...threadParams,
...(opts.silent === true ? { disable_notification: true } : {}),
...(params?.reply_markup ? { reply_markup: params.reply_markup } : {}),
};
const result = await requestWithChatNotFound(
() =>
richRawApi.sendRichMessage({
chat_id: chatId,
rich_message: buildTelegramRichMessage(chunk.text, chunk.textMode, richMessageOptions),
...richParams,
}),
"richMessage",
);
return { result, acceptedParams: params };
if (account.config.linkPreview === false) {
legacyParams.link_preview_options = { is_disabled: true };
}
const plainParams = { ...legacyParams };
delete plainParams.parse_mode;
const result = await withTelegramHtmlParseFallback({
label: "sendMessage",
verbose: opts.verbose,
requestHtml: (label) =>
requestWithChatNotFound(
() =>
api.sendMessage(
chatId,
chunk.text,
legacyParams as Parameters<TelegramApi["sendMessage"]>[2],
) as Promise<TelegramMessageLike>,
label,
),
requestPlain: (label) =>
requestWithChatNotFound(
() =>
api.sendMessage(
chatId,
telegramHtmlToPlainTextFallback(chunk.text),
plainParams as Parameters<TelegramApi["sendMessage"]>[2],
) as Promise<TelegramMessageLike>,
label,
),
});
return { result, acceptedParams: threadParams, operation: "sendMessage" };
};
const buildTextParams = (isLastChunk: boolean) =>
hasRichThreadParams || (isLastChunk && replyMarkup)
? {
...richThreadParams,
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
}
: undefined;
useRichTextSend
? hasRichThreadParams || (isLastChunk && replyMarkup)
? {
...richThreadParams,
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
}
: undefined
: isLastChunk && replyMarkup
? { reply_markup: replyMarkup }
: undefined;
const sendTelegramTextChunks = async (
chunks: TelegramRichTextChunk[],
@@ -656,16 +713,18 @@ export async function sendMessageTelegram(
let lastMessageId = "";
let lastChatId = chatId;
let lastAcceptedParams: TelegramThreadScopedParams | undefined;
let lastOperation = "";
let sentChunkCount = 0;
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index];
if (!chunk) {
continue;
}
const { result: res, acceptedParams } = await sendTelegramTextChunk(
chunk,
buildTextParams(index === chunks.length - 1),
);
const {
result: res,
acceptedParams,
operation,
} = await sendTelegramTextChunk(chunk, buildTextParams(index === chunks.length - 1));
const messageId = resolveTelegramMessageIdOrThrow(res, context);
recordSentMessage(chatId, messageId, cfg);
await recordOutboundMessageForPromptContext({
@@ -682,6 +741,7 @@ export async function sendMessageTelegram(
lastMessageId = String(messageId);
lastChatId = String(res?.chat?.id ?? chatId);
lastAcceptedParams = acceptedParams;
lastOperation = operation;
sentChunkCount += 1;
}
if (lastMessageId) {
@@ -689,7 +749,7 @@ export async function sendMessageTelegram(
accountId: account.accountId,
chatId: lastChatId,
messageId: lastMessageId,
operation: "sendRichMessage",
operation: lastOperation,
deliveryKind: "text",
messageThreadId: lastAcceptedParams?.message_thread_id,
replyToMessageId: opts.replyToMessageId,
@@ -701,6 +761,13 @@ export async function sendMessageTelegram(
};
const buildChunkedTextPlan = (rawText: string): TelegramRichTextChunk[] => {
if (!useRichTextSend) {
return splitTelegramHtmlChunks(renderHtmlText(rawText), textLimit).map((chunkText) => ({
text: chunkText,
textMode: "html",
plainText: telegramHtmlToPlainTextFallback(chunkText),
}));
}
return splitTelegramRichMessageTextChunks({
text: rawText,
textLimit,
@@ -1290,13 +1357,16 @@ export async function editMessageReplyMarkupTelegram(
gatewayClientScopes: opts.gatewayClientScopes,
});
const messageId = normalizeMessageId(messageIdInput);
const resolvedChatType = parseTelegramTarget(chatId).chatType;
const requestWithDiag = createTelegramRequestWithDiag({
cfg,
account,
retry: opts.retry,
verbose: opts.verbose,
});
const replyMarkup = buildInlineKeyboard(buttons) ?? { inline_keyboard: [] };
const replyMarkup = buildInlineKeyboard(buttons, { chatType: resolvedChatType }) ?? {
inline_keyboard: [],
};
try {
await requestWithDiag(
() => api.editMessageReplyMarkup(chatId, messageId, { reply_markup: replyMarkup }),
@@ -1334,6 +1404,7 @@ export async function editMessageTelegram(
gatewayClientScopes: opts.gatewayClientScopes,
});
const messageId = normalizeMessageId(messageIdInput);
const resolvedChatType = parseTelegramTarget(chatId).chatType;
const requestWithDiag = createTelegramRequestWithDiag({
cfg,
account,
@@ -1369,7 +1440,9 @@ export async function editMessageTelegram(
// - buttons is [] (or filters to empty) → send { inline_keyboard: [] } (remove)
// - otherwise → send built inline keyboard
const shouldTouchButtons = opts.buttons !== undefined;
const builtKeyboard = shouldTouchButtons ? buildInlineKeyboard(opts.buttons) : undefined;
const builtKeyboard = shouldTouchButtons
? buildInlineKeyboard(opts.buttons, { chatType: resolvedChatType })
: undefined;
const replyMarkup = shouldTouchButtons ? (builtKeyboard ?? { inline_keyboard: [] }) : undefined;
const textEditParams: Pick<TelegramEditRichMessageTextParams, "reply_markup"> = {};

View File

@@ -607,6 +607,46 @@ function rememberWrittenSessionEntries(
};
}
function refreshSessionEntriesCacheFromCurrentFile(filePath: string): {
snapshot: SessionFileSnapshot | undefined;
cacheRefreshed: boolean;
} {
const resolvedPath = resolve(filePath);
let beforeReadSnapshot: SessionFileSnapshot;
try {
beforeReadSnapshot = readSessionFileSnapshot(resolvedPath);
} catch {
sessionEntriesCache.delete(resolvedPath);
return { snapshot: undefined, cacheRefreshed: false };
}
if (beforeReadSnapshot.size > MAX_CACHED_SESSION_BYTES) {
sessionEntriesCache.delete(resolvedPath);
return { snapshot: beforeReadSnapshot, cacheRefreshed: false };
}
let content: string;
let afterReadSnapshot: SessionFileSnapshot;
try {
content = readFileSync(resolvedPath, "utf8");
afterReadSnapshot = readSessionFileSnapshot(resolvedPath);
} catch {
sessionEntriesCache.delete(resolvedPath);
return { snapshot: undefined, cacheRefreshed: false };
}
if (!isSameSessionFileSnapshot(beforeReadSnapshot, afterReadSnapshot)) {
sessionEntriesCache.delete(resolvedPath);
return { snapshot: afterReadSnapshot, cacheRefreshed: false };
}
rememberSessionEntries(
resolvedPath,
afterReadSnapshot,
parseJsonlEntries(content),
content.endsWith("\n"),
);
return { snapshot: afterReadSnapshot, cacheRefreshed: true };
}
function rememberAppendedSessionEntry(
filePath: string,
previousSnapshot: SessionFileSnapshot | undefined,
@@ -674,6 +714,15 @@ function rememberAppendedSessionEntry(
return { snapshot, cacheAdvanced: true };
}
function shouldRefreshSessionPrefixAfterSerialization(entry: SessionEntry): boolean {
return (
entry.type === "custom" ||
entry.type === "custom_message" ||
entry.type === "compaction" ||
entry.type === "branch_summary"
);
}
function publishRememberedSessionFileSnapshot(
filePath: string,
snapshot: SessionFileSnapshot | undefined,
@@ -1327,7 +1376,13 @@ export class SessionManager {
// user code and can mutate the transcript; the cache must validate the
// prefix state that immediately precedes the exact bytes being appended.
const serializedEntry = serializeJsonlEntry(entry);
const beforeAppendSnapshot = readSessionFileSnapshotIfExists(this.sessionFile);
let beforeAppendSnapshot = readSessionFileSnapshotIfExists(this.sessionFile);
let previousSnapshot = this.sessionFileSnapshot;
if (shouldRefreshSessionPrefixAfterSerialization(entry)) {
const refreshedPrefix = refreshSessionEntriesCacheFromCurrentFile(this.sessionFile);
beforeAppendSnapshot = refreshedPrefix.snapshot;
previousSnapshot = refreshedPrefix.cacheRefreshed ? refreshedPrefix.snapshot : undefined;
}
const cacheOwnedAppend = Boolean(
beforeAppendSnapshot &&
canAdvanceOwnedSessionEntryCache({
@@ -1340,7 +1395,7 @@ export class SessionManager {
});
const rememberedAppend = rememberAppendedSessionEntry(
this.sessionFile,
this.sessionFileSnapshot,
previousSnapshot,
beforeAppendSnapshot,
serializedAppend,
cacheOwnedAppend,