mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-01 17:23:35 +00:00
fix(telegram): track chunked outbound sends
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user