fix(telegram): track chunked outbound sends

This commit is contained in:
Ayaan Zaidi
2026-06-27 12:00:14 -07:00
parent b5854c6f77
commit bc2728c6b1
5 changed files with 401 additions and 46 deletions

View File

@@ -4445,7 +4445,7 @@ describe("createTelegramBot", () => {
});
expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1);
for (const [index, call] of sendMessageSpy.mock.calls.entries()) {
for (const call of sendMessageSpy.mock.calls) {
const params = call[2] as
| { reply_to_message_id?: number; reply_parameters?: { message_id?: number } }
| undefined;

View File

@@ -82,19 +82,37 @@ describe("telegram channel message adapter", () => {
};
const provePayload = async () => {
sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-payload", chatId: "12345" });
sendMessageTelegramMock.mockResolvedValueOnce({
messageId: "tg-payload-2",
chatId: "12345",
receipt: {
primaryPlatformMessageId: "tg-payload-1",
platformMessageIds: ["tg-payload-1", "tg-payload-2"],
parts: [
{ platformMessageId: "tg-payload-1", kind: "text", index: 0 },
{ platformMessageId: "tg-payload-2", kind: "text", index: 1 },
],
sentAt: 123,
},
});
const result = await adapter.send!.payload!({
cfg: {} as never,
to: "12345",
text: "payload",
payload: { text: "payload" },
replyToId: "900",
replyToIdSource: "implicit",
replyToMode: "first",
threadId: "12",
deps: { sendTelegram: sendMessageTelegramMock },
});
expect(sendMessageTelegramMock).toHaveBeenLastCalledWith("12345", "payload", {
cfg: {},
verbose: false,
messageThreadId: undefined,
replyToMessageId: undefined,
messageThreadId: 12,
replyToMessageId: 900,
replyToIdSource: "implicit",
replyToMode: "first",
accountId: undefined,
silent: undefined,
gatewayClientScopes: undefined,
@@ -104,7 +122,8 @@ describe("telegram channel message adapter", () => {
quoteText: undefined,
buttons: undefined,
});
expect(result.receipt.platformMessageIds).toEqual(["tg-payload"]);
expect(result.receipt.primaryPlatformMessageId).toBe("tg-payload-1");
expect(result.receipt.platformMessageIds).toEqual(["tg-payload-1", "tg-payload-2"]);
};
const proveReplyThreadSilent = async () => {
@@ -114,6 +133,8 @@ describe("telegram channel message adapter", () => {
to: "12345",
text: "threaded",
replyToId: "900",
replyToIdSource: "implicit",
replyToMode: "first",
threadId: "12",
silent: true,
deps: { sendTelegram: sendMessageTelegramMock },
@@ -123,6 +144,8 @@ describe("telegram channel message adapter", () => {
verbose: false,
messageThreadId: 12,
replyToMessageId: 900,
replyToIdSource: "implicit",
replyToMode: "first",
accountId: undefined,
silent: true,
gatewayClientScopes: undefined,
@@ -138,6 +161,9 @@ describe("telegram channel message adapter", () => {
cfg: {} as never,
to: "12345",
text: "batch",
replyToId: "900",
replyToIdSource: "implicit",
replyToMode: "first",
payload: {
text: "batch",
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
@@ -152,7 +178,9 @@ describe("telegram channel message adapter", () => {
cfg: {},
verbose: false,
messageThreadId: undefined,
replyToMessageId: undefined,
replyToMessageId: 900,
replyToIdSource: "implicit",
replyToMode: "first",
accountId: undefined,
silent: undefined,
gatewayClientScopes: undefined,
@@ -172,6 +200,8 @@ describe("telegram channel message adapter", () => {
verbose: false,
messageThreadId: undefined,
replyToMessageId: undefined,
replyToIdSource: undefined,
replyToMode: undefined,
accountId: undefined,
silent: undefined,
gatewayClientScopes: undefined,
@@ -222,6 +252,36 @@ describe("telegram channel message adapter", () => {
});
});
it("keeps implicit first replies on the first delivered payload media", async () => {
const adapter = requireTelegramMessageAdapter();
sendMessageTelegramMock
.mockResolvedValueOnce({ messageId: "tg-media-1", chatId: "12345" })
.mockResolvedValueOnce({ messageId: "tg-media-2", chatId: "12345" });
await adapter.send!.payload!({
cfg: {} as never,
to: "12345",
text: "batch",
replyToId: "900",
replyToIdSource: "implicit",
replyToMode: "first",
payload: {
text: "batch",
mediaUrls: ["", "https://example.com/a.png", "https://example.com/b.png"],
},
deps: { sendTelegram: sendMessageTelegramMock },
});
const firstOpts = sendMessageTelegramMock.mock.calls[0]?.[2] as
| { replyToMessageId?: number }
| undefined;
const secondOpts = sendMessageTelegramMock.mock.calls[1]?.[2] as
| { replyToMessageId?: number }
| undefined;
expect(firstOpts?.replyToMessageId).toBe(900);
expect(secondOpts?.replyToMessageId).toBeUndefined();
});
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
const adapter = requireTelegramMessageAdapter();

View File

@@ -19,6 +19,7 @@ import {
resolvePayloadMediaUrls,
sendPayloadMediaSequenceOrFallback,
} from "openclaw/plugin-sdk/reply-payload";
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
import type { TelegramInlineButtons } from "./button-types.js";
@@ -60,6 +61,8 @@ async function resolveTelegramSendContext(params: {
deps?: OutboundSendDeps;
accountId?: string | null;
replyToId?: string | null;
replyToIdSource?: TelegramSendOpts["replyToIdSource"];
replyToMode?: TelegramSendOpts["replyToMode"];
threadId?: string | number | null;
formatting?: OutboundDeliveryFormattingOptions;
silent?: boolean;
@@ -74,6 +77,8 @@ async function resolveTelegramSendContext(params: {
tableMode?: OutboundDeliveryFormattingOptions["tableMode"];
messageThreadId?: number;
replyToMessageId?: number;
replyToIdSource?: TelegramSendOpts["replyToIdSource"];
replyToMode?: TelegramSendOpts["replyToMode"];
accountId?: string;
silent?: boolean;
gatewayClientScopes?: readonly string[];
@@ -87,6 +92,8 @@ async function resolveTelegramSendContext(params: {
cfg: params.cfg,
messageThreadId: parseTelegramThreadId(params.threadId),
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
...(params.replyToIdSource !== undefined ? { replyToIdSource: params.replyToIdSource } : {}),
...(params.replyToMode !== undefined ? { replyToMode: params.replyToMode } : {}),
accountId: params.accountId ?? undefined,
silent: params.silent,
gatewayClientScopes: params.gatewayClientScopes,
@@ -151,6 +158,19 @@ export async function sendTelegramPayloadMessages(params: {
quoteText,
...(params.payload.audioAsVoice === true ? { asVoice: true } : {}),
};
const shouldConsumeImplicitReplyTarget =
payloadOpts.replyToIdSource === "implicit" &&
payloadOpts.replyToMode !== undefined &&
isSingleUseReplyToMode(payloadOpts.replyToMode);
const consumedImplicitReplyPayloadOpts = shouldConsumeImplicitReplyTarget
? {
...payloadOpts,
replyToMessageId: undefined,
replyToIdSource: undefined,
replyToMode: undefined,
}
: payloadOpts;
let implicitReplyTargetAvailable = true;
if (reactionEmoji) {
if (typeof replyToMessageId !== "number") {
throw new Error("Telegram reaction requires a reply target");
@@ -179,12 +199,18 @@ export async function sendTelegramPayloadMessages(params: {
...payloadOpts,
buttons,
}),
send: async ({ text: textLocal, mediaUrl, isFirst }) =>
await params.send(params.to, textLocal, {
...payloadOpts,
send: async ({ text: textLocal, mediaUrl, isFirst }) => {
const mediaPayloadOpts =
shouldConsumeImplicitReplyTarget && !implicitReplyTargetAvailable
? consumedImplicitReplyPayloadOpts
: payloadOpts;
implicitReplyTargetAvailable = false;
return await params.send(params.to, textLocal, {
...mediaPayloadOpts,
mediaUrl,
...(isFirst ? { buttons } : {}),
}),
});
},
});
}

View File

@@ -1650,6 +1650,49 @@ describe("sendMessageTelegram", () => {
expect(res.messageId).toBe("71");
});
it("does not reuse first-mode reply-to on media caption follow-up text", async () => {
const chatId = "123";
const longText = "A".repeat(1100);
const sendPhoto = vi.fn().mockResolvedValue({
message_id: 70,
chat: { id: chatId },
});
const sendMessage = vi.fn().mockResolvedValue({
message_id: 71,
chat: { id: chatId },
});
const api = { sendPhoto, sendMessage } as unknown as {
sendPhoto: typeof sendPhoto;
sendMessage: typeof sendMessage;
};
mockLoadedMedia({
buffer: Buffer.from("fake-image"),
contentType: "image/jpeg",
fileName: "photo.jpg",
});
await sendMessageTelegram(chatId, longText, {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
api,
mediaUrl: "https://example.com/photo.jpg",
replyToMessageId: 500,
replyToIdSource: "implicit",
replyToMode: "first",
});
expectMediaSendCall(firstMockCall(sendPhoto, "send photo call"), "send photo call", chatId, {
caption: undefined,
reply_to_message_id: 500,
allow_sending_without_reply: true,
});
expect(sendMessage).toHaveBeenCalledWith(chatId, longText, {
parse_mode: "HTML",
});
});
it("chunks long default markdown media follow-up text", async () => {
const chatId = "123";
const longText = `**${"A".repeat(5000)}**`;
@@ -1658,7 +1701,10 @@ describe("sendMessageTelegram", () => {
message_id: 72,
chat: { id: chatId },
});
const sendMessage = vi.fn().mockResolvedValue({ message_id: 74, chat: { id: chatId } });
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ message_id: 73, chat: { id: chatId } })
.mockResolvedValueOnce({ message_id: 74, chat: { id: chatId } });
const api = { sendPhoto, sendMessage } as unknown as {
sendPhoto: typeof sendPhoto;
sendMessage: typeof sendMessage;
@@ -1684,6 +1730,9 @@ describe("sendMessageTelegram", () => {
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");
expect(res.receipt?.primaryPlatformMessageId).toBe("73");
expect(res.receipt?.platformMessageIds).toEqual(["73", "74"]);
expect(res.receipt?.parts.map((part) => part.kind)).toEqual(["text", "text"]);
});
it("uses caption when text is within 1024 char limit", async () => {
@@ -2499,6 +2548,93 @@ describe("sendMessageTelegram", () => {
}
});
it("returns a multipart receipt and avoids native replies for chunked first-mode text", async () => {
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ message_id: 101, chat: { id: "-1001234567890" } })
.mockResolvedValueOnce({ message_id: 102, chat: { id: "-1001234567890" } });
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
const result = await sendMessageTelegram("-1001234567890", `BEGIN ${"A".repeat(4100)} END`, {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
api,
messageThreadId: 271,
replyToMessageId: 500,
replyToIdSource: "implicit",
replyToMode: "first",
});
expect(sendMessage).toHaveBeenCalledTimes(2);
expect(sendMessage.mock.calls[0]?.[2]).toEqual({
parse_mode: "HTML",
message_thread_id: 271,
});
expect(sendMessage.mock.calls[1]?.[2]).toEqual({
parse_mode: "HTML",
message_thread_id: 271,
});
expect(result.messageId).toBe("102");
expect(result.receipt?.primaryPlatformMessageId).toBe("101");
expect(result.receipt?.platformMessageIds).toEqual(["101", "102"]);
expect(result.receipt?.threadId).toBe("271");
expect(result.receipt?.replyToId).toBeUndefined();
expect(
result.receipt?.parts.map(({ platformMessageId, kind, index, threadId, replyToId }) => ({
platformMessageId,
kind,
index,
threadId,
replyToId,
})),
).toEqual([
{
platformMessageId: "101",
kind: "text",
index: 0,
threadId: "271",
replyToId: undefined,
},
{
platformMessageId: "102",
kind: "text",
index: 1,
threadId: "271",
replyToId: undefined,
},
]);
});
it("keeps explicit native replies for chunked first-mode text", async () => {
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ message_id: 101, chat: { id: "-1001234567890" } })
.mockResolvedValueOnce({ message_id: 102, chat: { id: "-1001234567890" } });
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await sendMessageTelegram("-1001234567890", `BEGIN ${"A".repeat(4100)} END`, {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
api,
replyToMessageId: 500,
replyToIdSource: "explicit",
replyToMode: "first",
});
expect(sendMessage.mock.calls[0]?.[2]).toMatchObject({
reply_to_message_id: 500,
allow_sending_without_reply: true,
});
expect(sendMessage.mock.calls[1]?.[2]).toMatchObject({
reply_to_message_id: 500,
allow_sending_without_reply: true,
});
});
it("fails topic sends instead of retrying without message_thread_id", async () => {
const cases = [{ name: "forum", chatId: "-100123", text: "hello forum" }] as const;
const threadErr = new Error("400: Bad Request: message thread not found");

View File

@@ -3,12 +3,17 @@ 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 {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
} from "openclaw/plugin-sdk/channel-outbound";
import type { MarkdownTableMode, ReplyToMode } from "openclaw/plugin-sdk/config-contracts";
import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime";
import { formatUncaughtError } from "openclaw/plugin-sdk/error-runtime";
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
import { parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
import { createTelegramRetryRunner, type RetryConfig } from "openclaw/plugin-sdk/retry-runtime";
import { createSubsystemLogger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -84,6 +89,8 @@ type TelegramEditMessageCaptionParams = Parameters<TelegramApi["editMessageCapti
type TelegramCreateForumTopicParams = NonNullable<Parameters<TelegramApi["createForumTopic"]>[2]>;
type TelegramThreadScopedParams = {
message_thread_id?: number;
reply_parameters?: { message_id?: number };
reply_to_message_id?: number;
};
const InputFileCtor = grammy.InputFile;
const MAX_TELEGRAM_PHOTO_DIMENSION_SUM = 10_000;
@@ -111,6 +118,10 @@ type TelegramSendOpts = {
silent?: boolean;
/** Message ID to reply to (for threading) */
replyToMessageId?: number;
/** Whether replyToMessageId came from ambient context or explicit payload/action input. */
replyToIdSource?: "explicit" | "implicit";
/** Controls whether replyToMessageId is applied to every internal text chunk. */
replyToMode?: ReplyToMode;
/** Quote text for Telegram reply_parameters. */
quoteText?: string;
/** Forum topic thread ID (for forum supergroups) */
@@ -124,6 +135,7 @@ type TelegramSendOpts = {
type TelegramSendResult = {
messageId: string;
chatId: string;
receipt?: MessageReceipt;
};
type TelegramMessageLike = {
@@ -274,6 +286,42 @@ function logTelegramOutboundSendOk(params: TelegramOutboundSuccessLogParams): vo
sendLogger.info(parts.join(" "));
}
function buildTelegramTextSendReceipt(params: {
messageIds: readonly string[];
chatId: string;
messageThreadId?: number;
replyToMessageId?: number;
}): MessageReceipt | undefined {
if (params.messageIds.length <= 1) {
return undefined;
}
return createMessageReceiptFromOutboundResults({
results: params.messageIds.map((messageId) => ({
messageId,
chatId: params.chatId,
})),
kind: "text",
...(typeof params.messageThreadId === "number"
? { threadId: String(params.messageThreadId) }
: {}),
...(typeof params.replyToMessageId === "number"
? { replyToId: String(params.replyToMessageId) }
: {}),
});
}
function resolveAcceptedReplyToMessageId(
params: TelegramThreadScopedParams | TelegramRichMessageContextParams | undefined,
): number | undefined {
if (!params) {
return undefined;
}
if ("reply_to_message_id" in params) {
return params.reply_to_message_id;
}
return params.reply_parameters?.message_id;
}
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;
@@ -661,19 +709,26 @@ export async function sendMessageTelegram(
(typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb : 100) * 1024 * 1024;
const replyMarkup = buildInlineKeyboard(opts.buttons);
const threadParams = buildTelegramThreadReplyParams({
thread: resolveTelegramSendThreadSpec({
targetMessageThreadId: target.messageThreadId,
messageThreadId: opts.messageThreadId,
chatType: target.chatType,
}),
replyToMessageId: opts.replyToMessageId,
replyQuoteText: opts.quoteText,
useReplyIdAsQuoteSource: true,
const threadSpec = resolveTelegramSendThreadSpec({
targetMessageThreadId: target.messageThreadId,
messageThreadId: opts.messageThreadId,
chatType: target.chatType,
});
const richThreadParams = toTelegramRichMessageContextParams(threadParams);
const hasThreadParams = Object.keys(threadParams).length > 0;
const hasRichThreadParams = Object.keys(richThreadParams).length > 0;
const singleUseReplyTo =
opts.replyToIdSource === "implicit" &&
opts.replyToMode !== undefined &&
isSingleUseReplyToMode(opts.replyToMode);
const buildThreadParams = (includeReplyTo: boolean) =>
buildTelegramThreadReplyParams({
thread: threadSpec,
...(includeReplyTo
? {
replyToMessageId: opts.replyToMessageId,
replyQuoteText: opts.quoteText,
useReplyIdAsQuoteSource: true,
}
: {}),
});
const requestWithDiag = createTelegramNonIdempotentRequestWithDiag({
cfg,
account,
@@ -746,29 +801,59 @@ export async function sendMessageTelegram(
return { result, acceptedParams: params };
};
const buildTextParams = (isLastChunk: boolean) =>
hasThreadParams || (isLastChunk && replyMarkup)
? {
...threadParams,
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
}
: undefined;
const shouldIncludeReplyForChunk = (
index: number,
chunkCount: number,
replyToAlreadyUsed: boolean,
) =>
// Telegram Desktop can render long formatted reply chunks as unsupported messages.
// Multi-part `first` replies keep chat/topic routing but avoid hiding chunk text.
!replyToAlreadyUsed && (!singleUseReplyTo || (chunkCount === 1 && index === 0));
const buildRichTextParams = (isLastChunk: boolean) =>
hasRichThreadParams || (isLastChunk && replyMarkup)
const buildTextParams = (
index: number,
chunkCount: number,
isLastChunk: boolean,
replyToAlreadyUsed: boolean,
) => {
const params = buildThreadParams(
shouldIncludeReplyForChunk(index, chunkCount, replyToAlreadyUsed),
);
return Object.keys(params).length > 0 || (isLastChunk && replyMarkup)
? {
...richThreadParams,
...params,
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
}
: undefined;
};
const buildRichTextParams = (
index: number,
chunkCount: number,
isLastChunk: boolean,
replyToAlreadyUsed: boolean,
) => {
const params = toTelegramRichMessageContextParams(
buildThreadParams(shouldIncludeReplyForChunk(index, chunkCount, replyToAlreadyUsed)),
);
return Object.keys(params).length > 0 || (isLastChunk && replyMarkup)
? {
...params,
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
}
: undefined;
};
const sendTelegramTextChunks = async (
chunks: TelegramTextChunk[],
context: string,
): Promise<{ messageId: string; chatId: string }> => {
options: { replyToAlreadyUsed?: boolean } = {},
): Promise<TelegramSendResult> => {
let lastMessageId = "";
let lastChatId = chatId;
let lastAcceptedParams: TelegramThreadScopedParams | undefined;
let acceptedReplyToMessageId: number | undefined;
const messageIds: string[] = [];
let sentChunkCount = 0;
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index];
@@ -777,7 +862,12 @@ export async function sendMessageTelegram(
}
const { result: res, acceptedParams } = await sendTelegramTextChunk(
chunk,
buildTextParams(index === chunks.length - 1),
buildTextParams(
index,
chunks.length,
index === chunks.length - 1,
options.replyToAlreadyUsed === true,
),
);
const messageId = resolveTelegramMessageIdOrThrow(res, context);
recordSentMessage(chatId, messageId, cfg);
@@ -795,6 +885,8 @@ export async function sendMessageTelegram(
lastMessageId = String(messageId);
lastChatId = String(res?.chat?.id ?? chatId);
lastAcceptedParams = acceptedParams;
acceptedReplyToMessageId ??= resolveAcceptedReplyToMessageId(acceptedParams);
messageIds.push(lastMessageId);
sentChunkCount += 1;
}
if (lastMessageId) {
@@ -810,7 +902,17 @@ export async function sendMessageTelegram(
chunkCount: sentChunkCount,
});
}
return { messageId: lastMessageId, chatId: lastChatId };
const receipt = buildTelegramTextSendReceipt({
messageIds,
chatId: lastChatId,
messageThreadId: lastAcceptedParams?.message_thread_id,
replyToMessageId: acceptedReplyToMessageId,
});
return {
messageId: lastMessageId,
chatId: lastChatId,
...(receipt ? { receipt } : {}),
};
};
const buildChunkedTextPlan = (rawText: string, context: string): TelegramTextChunk[] => {
@@ -841,10 +943,14 @@ export async function sendMessageTelegram(
}));
};
const sendChunkedText = async (rawText: string, context: string) =>
const sendChunkedText = async (
rawText: string,
context: string,
options: { replyToAlreadyUsed?: boolean } = {},
) =>
useRichMessages
? await sendTelegramRichTextChunks(buildRichTextPlan(rawText), context)
: await sendTelegramTextChunks(buildChunkedTextPlan(rawText, context), context);
? await sendTelegramRichTextChunks(buildRichTextPlan(rawText), context, options)
: await sendTelegramTextChunks(buildChunkedTextPlan(rawText, context), context, options);
const buildRichTextPlan = (rawText: string): TelegramRichTextChunk[] => {
const textLimit = Math.min(
@@ -866,18 +972,26 @@ export async function sendMessageTelegram(
const sendTelegramRichTextChunks = async (
chunks: TelegramRichTextChunk[],
context: string,
): Promise<{ messageId: string; chatId: string }> => {
options: { replyToAlreadyUsed?: boolean } = {},
): Promise<TelegramSendResult> => {
const richRawApi = getTelegramRichRawApi(api);
let lastMessageId = "";
let lastChatId = chatId;
let lastAcceptedParams: TelegramRichMessageContextParams | undefined;
let acceptedReplyToMessageId: number | undefined;
const messageIds: string[] = [];
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 acceptedParams = buildRichTextParams(
index,
chunks.length,
index === chunks.length - 1,
options.replyToAlreadyUsed === true,
);
const result = await requestWithChatNotFound(
() =>
richRawApi.sendRichMessage({
@@ -907,6 +1021,8 @@ export async function sendMessageTelegram(
lastMessageId = String(messageId);
lastChatId = String(result?.chat?.id ?? chatId);
lastAcceptedParams = acceptedParams;
acceptedReplyToMessageId ??= resolveAcceptedReplyToMessageId(acceptedParams);
messageIds.push(lastMessageId);
sentChunkCount += 1;
}
if (lastMessageId) {
@@ -922,7 +1038,17 @@ export async function sendMessageTelegram(
chunkCount: sentChunkCount,
});
}
return { messageId: lastMessageId, chatId: lastChatId };
const receipt = buildTelegramTextSendReceipt({
messageIds,
chatId: lastChatId,
messageThreadId: lastAcceptedParams?.message_thread_id,
replyToMessageId: acceptedReplyToMessageId,
});
return {
messageId: lastMessageId,
chatId: lastChatId,
...(receipt ? { receipt } : {}),
};
};
async function shouldSendTelegramImageAsPhoto(buffer: Buffer): Promise<boolean> {
@@ -1001,8 +1127,10 @@ export async function sendMessageTelegram(
const needsSeparateText = Boolean(followUpText);
// When splitting, put reply_markup only on the follow-up text (the "main" content),
// not on the media message.
const mediaThreadParams = buildThreadParams(true);
const mediaUsedReplyTo = resolveAcceptedReplyToMessageId(mediaThreadParams) !== undefined;
const baseMediaParams = {
...(hasThreadParams ? threadParams : {}),
...mediaThreadParams,
...(!needsSeparateText && replyMarkup ? { reply_markup: replyMarkup } : {}),
};
const videoDimensions =
@@ -1145,8 +1273,13 @@ export async function sendMessageTelegram(
// If text was too long for a caption, send it as a separate follow-up message.
// Use HTML conversion so markdown renders like captions.
if (needsSeparateText && followUpText) {
const textResult = await sendChunkedText(followUpText, "text follow-up send");
return { messageId: textResult.messageId, chatId: resolvedChatId };
const textResult = await sendChunkedText(followUpText, "text follow-up send", {
replyToAlreadyUsed: singleUseReplyTo && mediaUsedReplyTo,
});
return {
...textResult,
chatId: resolvedChatId,
};
}
return { messageId: String(mediaMessageId), chatId: resolvedChatId };