mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
fix(telegram): preserve native quote replies
Preserve exact Telegram selected quote text for native quote replies, share Telegram reply parameter construction between bot delivery and direct outbound sends, and retry with legacy replies when Telegram rejects native quote parameters.\n\nThanks @rubencu.
This commit is contained in:
@@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Telegram: preserve exact selected quote text when sending native quote replies, and retry with legacy replies if Telegram rejects quote parameters. (#71952) Thanks @rubencu.
|
||||
- Plugins/CLI: preserve manifest name, description, format, and source metadata in cold `openclaw plugins list` output without importing plugin runtime. Thanks @shakkernerd.
|
||||
- Security/audit: read channel exposure and plugin allowlist ownership from read-only plugin index metadata so cold audits do not depend on loaded channel runtime. Thanks @shakkernerd.
|
||||
- Logging: redact configured secret patterns at console and file-log sink exits
|
||||
|
||||
@@ -346,6 +346,10 @@ export async function buildTelegramInboundContextPayload(params: {
|
||||
ReplyToBody: visibleReplyTarget?.body,
|
||||
ReplyToSender: visibleReplyTarget?.sender,
|
||||
ReplyToIsQuote: visibleReplyTarget?.kind === "quote" ? true : undefined,
|
||||
ReplyToIsExternal: visibleReplyTarget?.source === "external_reply" ? true : undefined,
|
||||
ReplyToQuoteText: visibleReplyTarget?.quoteText,
|
||||
ReplyToQuotePosition: visibleReplyTarget?.quotePosition,
|
||||
ReplyToQuoteEntities: visibleReplyTarget?.quoteEntities,
|
||||
ReplyToForwardedFrom: visibleReplyTarget?.forwardedFrom?.from,
|
||||
ReplyToForwardedFromType: visibleReplyTarget?.forwardedFrom?.fromType,
|
||||
ReplyToForwardedFromId: visibleReplyTarget?.forwardedFrom?.fromId,
|
||||
|
||||
@@ -365,6 +365,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
streamMode?: Parameters<typeof dispatchTelegramMessage>[0]["streamMode"];
|
||||
telegramDeps?: TelegramBotDeps;
|
||||
bot?: Bot;
|
||||
replyToMode?: Parameters<typeof dispatchTelegramMessage>[0]["replyToMode"];
|
||||
}) {
|
||||
const bot = params.bot ?? createBot();
|
||||
await dispatchTelegramMessage({
|
||||
@@ -372,7 +373,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
bot,
|
||||
cfg: params.cfg ?? {},
|
||||
runtime: createRuntime(),
|
||||
replyToMode: "first",
|
||||
replyToMode: params.replyToMode ?? "first",
|
||||
streamMode: params.streamMode ?? "partial",
|
||||
textLimit: 4096,
|
||||
telegramCfg: params.telegramCfg ?? {},
|
||||
@@ -439,6 +440,130 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips answer draft preview for same-chat selected quotes", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "Hello", replyToId: "1001" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
deliverReplies.mockResolvedValue({ delivered: true });
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext({
|
||||
msg: {
|
||||
message_id: 1001,
|
||||
} as unknown as TelegramMessageContext["msg"],
|
||||
ctxPayload: {
|
||||
MessageSid: "1001",
|
||||
ReplyToId: "9001",
|
||||
ReplyToBody: "quoted slice",
|
||||
ReplyToQuoteText: " quoted slice\n",
|
||||
ReplyToIsQuote: true,
|
||||
} as unknown as TelegramMessageContext["ctxPayload"],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(createTelegramDraftStream).not.toHaveBeenCalled();
|
||||
expect(deliverReplies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replies: [expect.objectContaining({ replyToId: "9001" })],
|
||||
replyQuoteMessageId: 9001,
|
||||
replyQuoteText: " quoted slice\n",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps answer draft preview for selected quotes when reply mode is off", async () => {
|
||||
const draftStream = createDraftStream();
|
||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ queuedFinal: true });
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext({
|
||||
msg: {
|
||||
message_id: 1001,
|
||||
} as unknown as TelegramMessageContext["msg"],
|
||||
ctxPayload: {
|
||||
MessageSid: "1001",
|
||||
ReplyToId: "9001",
|
||||
ReplyToBody: "quoted slice",
|
||||
ReplyToQuoteText: " quoted slice\n",
|
||||
ReplyToIsQuote: true,
|
||||
} as unknown as TelegramMessageContext["ctxPayload"],
|
||||
}),
|
||||
replyToMode: "off",
|
||||
});
|
||||
|
||||
expect(createTelegramDraftStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes same-chat quoted reply target id with Telegram quote text", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "Hello", replyToId: "1001" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
deliverReplies.mockResolvedValue({ delivered: true });
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext({
|
||||
ctxPayload: {
|
||||
MessageSid: "1001",
|
||||
ReplyToId: "9001",
|
||||
ReplyToBody: "quoted slice",
|
||||
ReplyToQuoteText: " quoted slice\n",
|
||||
ReplyToIsQuote: true,
|
||||
ReplyToQuotePosition: 12,
|
||||
ReplyToQuoteEntities: [{ type: "italic", offset: 0, length: 6 }],
|
||||
} as unknown as TelegramMessageContext["ctxPayload"],
|
||||
}),
|
||||
streamMode: "off",
|
||||
});
|
||||
|
||||
expect(deliverReplies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replies: [expect.objectContaining({ replyToId: "9001" })],
|
||||
replyQuoteMessageId: 9001,
|
||||
replyQuoteText: " quoted slice\n",
|
||||
replyQuotePosition: 12,
|
||||
replyQuoteEntities: [{ type: "italic", offset: 0, length: 6 }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not pass a native quote target for external replies", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "Hello", replyToId: "1001" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
deliverReplies.mockResolvedValue({ delivered: true });
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext({
|
||||
ctxPayload: {
|
||||
MessageSid: "1001",
|
||||
ReplyToId: "9001",
|
||||
ReplyToBody: "external quoted slice",
|
||||
ReplyToQuoteText: " external quoted slice\n",
|
||||
ReplyToIsQuote: true,
|
||||
ReplyToIsExternal: true,
|
||||
} as unknown as TelegramMessageContext["ctxPayload"],
|
||||
}),
|
||||
streamMode: "off",
|
||||
});
|
||||
|
||||
const params = deliverReplies.mock.calls[0]?.[0];
|
||||
expect(params).toEqual(
|
||||
expect.objectContaining({
|
||||
replies: [expect.objectContaining({ replyToId: "1001" })],
|
||||
replyQuoteText: " external quoted slice\n",
|
||||
}),
|
||||
);
|
||||
expect(params?.replyQuoteMessageId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inject approval buttons in local dispatch once the monitor owns approvals", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver(
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from "./bot-message-dispatch.runtime.js";
|
||||
import type { TelegramBotOptions } from "./bot.types.js";
|
||||
import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js";
|
||||
import { resolveTelegramReplyId } from "./bot/helpers.js";
|
||||
import type { TelegramStreamMode } from "./bot/types.js";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||
@@ -340,11 +341,31 @@ export const dispatchTelegramMessage = async ({
|
||||
const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on";
|
||||
const streamReasoningDraft = resolvedReasoningLevel === "stream";
|
||||
const previewStreamingEnabled = streamMode !== "off";
|
||||
const rawReplyQuoteText =
|
||||
ctxPayload.ReplyToIsQuote && typeof ctxPayload.ReplyToQuoteText === "string"
|
||||
? ctxPayload.ReplyToQuoteText
|
||||
: undefined;
|
||||
const replyQuoteText = ctxPayload.ReplyToIsQuote
|
||||
? rawReplyQuoteText?.trim()
|
||||
? rawReplyQuoteText
|
||||
: ctxPayload.ReplyToBody?.trim() || undefined
|
||||
: undefined;
|
||||
const replyQuoteMessageId =
|
||||
replyQuoteText && !ctxPayload.ReplyToIsExternal
|
||||
? resolveTelegramReplyId(ctxPayload.ReplyToId)
|
||||
: undefined;
|
||||
const hasNativeQuoteReply =
|
||||
replyToMode !== "off" && replyQuoteText != null && replyQuoteMessageId != null;
|
||||
const canStreamAnswerDraft =
|
||||
previewStreamingEnabled && !accountBlockStreamingEnabled && !forceBlockStreamingForReasoning;
|
||||
previewStreamingEnabled &&
|
||||
!hasNativeQuoteReply &&
|
||||
!accountBlockStreamingEnabled &&
|
||||
!forceBlockStreamingForReasoning;
|
||||
const canStreamReasoningDraft = streamReasoningDraft;
|
||||
const draftReplyToMessageId =
|
||||
replyToMode !== "off" && typeof msg.message_id === "number" ? msg.message_id : undefined;
|
||||
replyToMode !== "off" && typeof msg.message_id === "number"
|
||||
? (replyQuoteMessageId ?? msg.message_id)
|
||||
: undefined;
|
||||
const draftMinInitialChars = DRAFT_MIN_INITIAL_CHARS;
|
||||
// DM draft previews still duplicate briefly at materialize time.
|
||||
const useMessagePreviewTransportForDm = threadSpec?.scope === "dm" && canStreamAnswerDraft;
|
||||
@@ -558,10 +579,17 @@ export const dispatchTelegramMessage = async ({
|
||||
supersede: shouldSupersedeAbortFence,
|
||||
});
|
||||
|
||||
const replyQuoteText =
|
||||
ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody
|
||||
? ctxPayload.ReplyToBody.trim() || undefined
|
||||
const implicitQuoteReplyTargetId =
|
||||
replyQuoteMessageId != null ? String(replyQuoteMessageId) : undefined;
|
||||
const currentMessageIdForQuoteReply =
|
||||
implicitQuoteReplyTargetId && ctxPayload.MessageSid ? ctxPayload.MessageSid : undefined;
|
||||
const replyQuotePosition =
|
||||
typeof ctxPayload.ReplyToQuotePosition === "number"
|
||||
? ctxPayload.ReplyToQuotePosition
|
||||
: undefined;
|
||||
const replyQuoteEntities = Array.isArray(ctxPayload.ReplyToQuoteEntities)
|
||||
? ctxPayload.ReplyToQuoteEntities
|
||||
: undefined;
|
||||
const deliveryState = createLaneDeliveryStateTracker();
|
||||
const clearGroupHistory = () => {
|
||||
if (isGroup && historyKey) {
|
||||
@@ -588,7 +616,10 @@ export const dispatchTelegramMessage = async ({
|
||||
tableMode,
|
||||
chunkMode,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
replyQuoteMessageId,
|
||||
replyQuoteText,
|
||||
replyQuotePosition,
|
||||
replyQuoteEntities,
|
||||
};
|
||||
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
|
||||
const isDmTopic = !isGroup && threadSpec.scope === "dm" && threadSpec.id != null;
|
||||
@@ -644,13 +675,25 @@ export const dispatchTelegramMessage = async ({
|
||||
}
|
||||
return { ...payload, text };
|
||||
};
|
||||
const applyQuoteReplyTarget = (payload: ReplyPayload): ReplyPayload => {
|
||||
if (
|
||||
!implicitQuoteReplyTargetId ||
|
||||
!currentMessageIdForQuoteReply ||
|
||||
payload.replyToId !== currentMessageIdForQuoteReply ||
|
||||
payload.replyToTag ||
|
||||
payload.replyToCurrent
|
||||
) {
|
||||
return payload;
|
||||
}
|
||||
return { ...payload, replyToId: implicitQuoteReplyTargetId };
|
||||
};
|
||||
const sendPayload = async (payload: ReplyPayload) => {
|
||||
if (isDispatchSuperseded()) {
|
||||
return false;
|
||||
}
|
||||
const result = await (telegramDeps.deliverReplies ?? deliverReplies)({
|
||||
...deliveryBaseOptions,
|
||||
replies: [payload],
|
||||
replies: [applyQuoteReplyTarget(payload)],
|
||||
onVoiceRecording: sendRecordVoice,
|
||||
silent: silentErrorReplies && payload.isError === true,
|
||||
mediaLoader: telegramDeps.loadWebMedia,
|
||||
|
||||
@@ -1502,7 +1502,9 @@ describe("createTelegramBot", () => {
|
||||
from: { first_name: "Ada" },
|
||||
},
|
||||
quote: {
|
||||
text: "summarize this",
|
||||
text: " summarize this\n",
|
||||
position: 8,
|
||||
entities: [{ type: "bold", offset: 1, length: 9 }],
|
||||
},
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
@@ -1516,6 +1518,10 @@ describe("createTelegramBot", () => {
|
||||
expect(payload.ReplyToId).toBe("9001");
|
||||
expect(payload.ReplyToBody).toBe("summarize this");
|
||||
expect(payload.ReplyToSender).toBe("Ada");
|
||||
const telegramPayload = payload as Record<string, unknown>;
|
||||
expect(telegramPayload.ReplyToQuoteText).toBe(" summarize this\n");
|
||||
expect(telegramPayload.ReplyToQuotePosition).toBe(8);
|
||||
expect(telegramPayload.ReplyToQuoteEntities).toEqual([{ type: "bold", offset: 1, length: 9 }]);
|
||||
});
|
||||
|
||||
it("keeps reply linkage while omitting filtered binary reply captions", async () => {
|
||||
@@ -1782,7 +1788,7 @@ describe("createTelegramBot", () => {
|
||||
expect(payload.ReplyToSender).toBe("unknown sender");
|
||||
});
|
||||
|
||||
it("uses external_reply quote text for partial replies", async () => {
|
||||
it("uses top-level quote text for external partial replies", async () => {
|
||||
onSpy.mockClear();
|
||||
sendMessageSpy.mockClear();
|
||||
replySpy.mockClear();
|
||||
@@ -1795,13 +1801,13 @@ describe("createTelegramBot", () => {
|
||||
chat: { id: 7, type: "private" },
|
||||
text: "Sure, see below",
|
||||
date: 1736380800,
|
||||
quote: {
|
||||
text: "summarize this",
|
||||
},
|
||||
external_reply: {
|
||||
message_id: 9002,
|
||||
text: "Can you summarize this?",
|
||||
from: { first_name: "Ada" },
|
||||
quote: {
|
||||
text: "summarize this",
|
||||
},
|
||||
},
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
@@ -1815,6 +1821,7 @@ describe("createTelegramBot", () => {
|
||||
expect(payload.ReplyToId).toBe("9002");
|
||||
expect(payload.ReplyToBody).toBe("summarize this");
|
||||
expect(payload.ReplyToSender).toBe("Ada");
|
||||
expect((payload as Record<string, unknown>).ReplyToIsExternal).toBe(true);
|
||||
});
|
||||
|
||||
it("propagates forwarded origin from external_reply targets", async () => {
|
||||
|
||||
@@ -113,7 +113,10 @@ async function deliverTextReply(params: {
|
||||
chunkText: ChunkTextFn;
|
||||
replyText: string;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyQuoteMessageId?: number;
|
||||
replyQuoteText?: string;
|
||||
replyQuotePosition?: number;
|
||||
replyQuoteEntities?: unknown[];
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyToId?: number;
|
||||
@@ -138,7 +141,10 @@ async function deliverTextReply(params: {
|
||||
params.runtime,
|
||||
{
|
||||
replyToMessageId,
|
||||
replyQuoteMessageId: params.replyQuoteMessageId,
|
||||
replyQuoteText,
|
||||
replyQuotePosition: params.replyQuotePosition,
|
||||
replyQuoteEntities: params.replyQuoteEntities,
|
||||
thread: params.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
@@ -212,6 +218,9 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
text: string;
|
||||
chunkText: (markdown: string) => ReturnType<typeof markdownToTelegramChunks>;
|
||||
replyToId?: number;
|
||||
replyQuoteMessageId?: number;
|
||||
replyQuotePosition?: number;
|
||||
replyQuoteEntities?: unknown[];
|
||||
thread?: TelegramThreadSpec | null;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
@@ -225,9 +234,13 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
const chunk = chunks[i];
|
||||
// Only apply reply reference, quote text, and buttons to the first chunk.
|
||||
const replyToForChunk = !appliedReplyTo ? opts.replyToId : undefined;
|
||||
const applyQuoteForChunk = !appliedReplyTo;
|
||||
const messageId = await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
|
||||
replyToMessageId: replyToForChunk,
|
||||
replyQuoteText: !appliedReplyTo ? opts.replyQuoteText : undefined,
|
||||
replyQuoteMessageId: applyQuoteForChunk ? opts.replyQuoteMessageId : undefined,
|
||||
replyQuoteText: applyQuoteForChunk ? opts.replyQuoteText : undefined,
|
||||
replyQuotePosition: applyQuoteForChunk ? opts.replyQuotePosition : undefined,
|
||||
replyQuoteEntities: applyQuoteForChunk ? opts.replyQuoteEntities : undefined,
|
||||
thread: opts.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
@@ -259,7 +272,10 @@ async function deliverMediaReply(params: {
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyQuoteMessageId?: number;
|
||||
replyQuoteText?: string;
|
||||
replyQuotePosition?: number;
|
||||
replyQuoteEntities?: unknown[];
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
@@ -303,6 +319,10 @@ async function deliverMediaReply(params: {
|
||||
...(shouldAttachButtonsToMedia ? { reply_markup: params.replyMarkup } : {}),
|
||||
...buildTelegramSendParams({
|
||||
replyToMessageId,
|
||||
replyQuoteMessageId: params.replyQuoteMessageId,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
replyQuotePosition: params.replyQuotePosition,
|
||||
replyQuoteEntities: params.replyQuoteEntities,
|
||||
thread: params.thread,
|
||||
silent: params.silent,
|
||||
}),
|
||||
@@ -396,6 +416,9 @@ async function deliverMediaReply(params: {
|
||||
text: fallbackText,
|
||||
chunkText: params.chunkText,
|
||||
replyToId: voiceFallbackReplyTo,
|
||||
replyQuoteMessageId: params.replyQuoteMessageId,
|
||||
replyQuotePosition: params.replyQuotePosition,
|
||||
replyQuoteEntities: params.replyQuoteEntities,
|
||||
thread: params.thread,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
@@ -612,8 +635,14 @@ export async function deliverReplies(params: {
|
||||
linkPreview?: boolean;
|
||||
/** When true, messages are sent with disable_notification. */
|
||||
silent?: boolean;
|
||||
/** Message id that the optional quote text belongs to. */
|
||||
replyQuoteMessageId?: number;
|
||||
/** Optional quote text for Telegram reply_parameters. */
|
||||
replyQuoteText?: string;
|
||||
/** UTF-16 position of the selected quote in the original Telegram message. */
|
||||
replyQuotePosition?: number;
|
||||
/** Telegram entities that belong to the selected quote text. */
|
||||
replyQuoteEntities?: unknown[];
|
||||
/** Override media loader (tests). */
|
||||
mediaLoader?: typeof loadWebMedia;
|
||||
}): Promise<{ delivered: boolean }> {
|
||||
@@ -721,7 +750,10 @@ export async function deliverReplies(params: {
|
||||
chunkText,
|
||||
replyText: reply.text || "",
|
||||
replyMarkup,
|
||||
replyQuoteMessageId: params.replyQuoteMessageId,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
replyQuotePosition: params.replyQuotePosition,
|
||||
replyQuoteEntities: params.replyQuoteEntities,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyToId,
|
||||
@@ -743,7 +775,10 @@ export async function deliverReplies(params: {
|
||||
onVoiceRecording: params.onVoiceRecording,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyQuoteMessageId: params.replyQuoteMessageId,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
replyQuotePosition: params.replyQuotePosition,
|
||||
replyQuoteEntities: params.replyQuoteEntities,
|
||||
replyMarkup,
|
||||
replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
|
||||
@@ -3,13 +3,20 @@ 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 { normalizeTelegramReplyToMessageId } from "../outbound-params.js";
|
||||
import {
|
||||
buildTelegramSendParams,
|
||||
getTelegramNativeQuoteReplyMessageId,
|
||||
removeTelegramNativeQuoteParam,
|
||||
} from "../reply-parameters.js";
|
||||
import { buildInlineKeyboard } from "../send.js";
|
||||
import { buildTelegramThreadParams, type TelegramThreadSpec } from "./helpers.js";
|
||||
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 THREAD_NOT_FOUND_RE = /message thread not found/i;
|
||||
const QUOTE_PARAM_RE = /\bquote not found\b/i;
|
||||
const GrammyErrorCtor: typeof GrammyError | undefined =
|
||||
typeof GrammyError === "function" ? GrammyError : undefined;
|
||||
|
||||
@@ -20,6 +27,13 @@ function isTelegramThreadNotFoundError(err: unknown): boolean {
|
||||
return THREAD_NOT_FOUND_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
function isTelegramQuoteParamError(err: unknown): boolean {
|
||||
if (GrammyErrorCtor && err instanceof GrammyErrorCtor) {
|
||||
return QUOTE_PARAM_RE.test(err.description);
|
||||
}
|
||||
return QUOTE_PARAM_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
function hasMessageThreadIdParam(params: Record<string, unknown> | undefined): boolean {
|
||||
if (!params) {
|
||||
return false;
|
||||
@@ -47,8 +61,10 @@ export async function sendTelegramWithThreadFallback<T>(params: {
|
||||
}): Promise<T> {
|
||||
const allowThreadlessRetry = params.thread?.scope === "dm";
|
||||
const hasThreadId = hasMessageThreadIdParam(params.requestParams);
|
||||
const hasNativeQuote = getTelegramNativeQuoteReplyMessageId(params.requestParams) != null;
|
||||
const shouldSuppressFirstErrorLog = (err: unknown) =>
|
||||
allowThreadlessRetry && hasThreadId && isTelegramThreadNotFoundError(err);
|
||||
(allowThreadlessRetry && hasThreadId && isTelegramThreadNotFoundError(err)) ||
|
||||
(hasNativeQuote && isTelegramQuoteParamError(err));
|
||||
const mergedShouldLog = params.shouldLog
|
||||
? (err: unknown) => params.shouldLog!(err) && !shouldSuppressFirstErrorLog(err)
|
||||
: (err: unknown) => !shouldSuppressFirstErrorLog(err);
|
||||
@@ -61,6 +77,16 @@ export async function sendTelegramWithThreadFallback<T>(params: {
|
||||
fn: () => params.send(params.requestParams),
|
||||
});
|
||||
} catch (err) {
|
||||
if (hasNativeQuote && isTelegramQuoteParamError(err)) {
|
||||
params.runtime.log?.(
|
||||
`telegram ${params.operation}: native quote rejected; retrying with legacy reply_to_message_id`,
|
||||
);
|
||||
return await sendTelegramWithThreadFallback({
|
||||
...params,
|
||||
operation: `${params.operation} (legacy reply retry)`,
|
||||
requestParams: removeTelegramNativeQuoteParam(params.requestParams),
|
||||
});
|
||||
}
|
||||
if (!allowThreadlessRetry || !hasThreadId || !isTelegramThreadNotFoundError(err)) {
|
||||
throw err;
|
||||
}
|
||||
@@ -76,27 +102,6 @@ export async function sendTelegramWithThreadFallback<T>(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTelegramSendParams(opts?: {
|
||||
replyToMessageId?: number;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
silent?: boolean;
|
||||
}): Record<string, unknown> {
|
||||
const threadParams = buildTelegramThreadParams(opts?.thread);
|
||||
const params: Record<string, unknown> = {};
|
||||
const replyToMessageId = normalizeTelegramReplyToMessageId(opts?.replyToMessageId);
|
||||
if (replyToMessageId != null) {
|
||||
params.reply_to_message_id = replyToMessageId;
|
||||
params.allow_sending_without_reply = true;
|
||||
}
|
||||
if (threadParams) {
|
||||
params.message_thread_id = threadParams.message_thread_id;
|
||||
}
|
||||
if (opts?.silent === true) {
|
||||
params.disable_notification = true;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function sendTelegramText(
|
||||
bot: Bot,
|
||||
chatId: string,
|
||||
@@ -104,7 +109,10 @@ export async function sendTelegramText(
|
||||
runtime: RuntimeEnv,
|
||||
opts?: {
|
||||
replyToMessageId?: number;
|
||||
replyQuoteMessageId?: number;
|
||||
replyQuoteText?: string;
|
||||
replyQuotePosition?: number;
|
||||
replyQuoteEntities?: unknown[];
|
||||
thread?: TelegramThreadSpec | null;
|
||||
textMode?: "markdown" | "html";
|
||||
plainText?: string;
|
||||
@@ -115,6 +123,10 @@ export async function sendTelegramText(
|
||||
): Promise<number> {
|
||||
const baseParams = buildTelegramSendParams({
|
||||
replyToMessageId: opts?.replyToMessageId,
|
||||
replyQuoteMessageId: opts?.replyQuoteMessageId,
|
||||
replyQuoteText: opts?.replyQuoteText,
|
||||
replyQuotePosition: opts?.replyQuotePosition,
|
||||
replyQuoteEntities: opts?.replyQuoteEntities,
|
||||
thread: opts?.thread,
|
||||
silent: opts?.silent,
|
||||
});
|
||||
|
||||
@@ -114,6 +114,12 @@ function createThreadNotFoundError(operation = "sendMessage") {
|
||||
);
|
||||
}
|
||||
|
||||
function createQuoteNotFoundError(operation = "sendMessage") {
|
||||
return new Error(
|
||||
`GrammyError: Call to '${operation}' failed! (400: Bad Request: quote not found)`,
|
||||
);
|
||||
}
|
||||
|
||||
function createVoiceFailureHarness(params: {
|
||||
voiceError: Error;
|
||||
sendMessageResult?: { message_id: number; chat: { id: string } };
|
||||
@@ -698,7 +704,7 @@ describe("deliverReplies", () => {
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses reply_to_message_id when quote text is provided", async () => {
|
||||
it("uses reply_parameters when quote text is provided", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 10,
|
||||
@@ -711,6 +717,87 @@ describe("deliverReplies", () => {
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "all",
|
||||
replyQuoteMessageId: 500,
|
||||
replyQuoteText: " quoted text\n",
|
||||
replyQuotePosition: 17,
|
||||
replyQuoteEntities: [{ type: "bold", offset: 0, length: 6 }],
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
reply_parameters: {
|
||||
message_id: 500,
|
||||
quote: " quoted text\n",
|
||||
quote_position: 17,
|
||||
quote_entities: [{ type: "bold", offset: 0, length: 6 }],
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.any(String),
|
||||
expect.not.objectContaining({
|
||||
reply_to_message_id: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("retries with legacy reply id when native quote parameters are rejected", 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",
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessage.mock.calls[0][2]).toEqual(
|
||||
expect.objectContaining({
|
||||
reply_parameters: {
|
||||
message_id: 500,
|
||||
quote: " quoted text\n",
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(sendMessage.mock.calls[1][2]).toEqual(
|
||||
expect.objectContaining({
|
||||
reply_to_message_id: 500,
|
||||
allow_sending_without_reply: true,
|
||||
}),
|
||||
);
|
||||
expect(sendMessage.mock.calls[1][2]).not.toHaveProperty("reply_parameters");
|
||||
});
|
||||
|
||||
it("uses legacy reply id when selected reply target differs from quote source", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 11,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "Hello there", replyToId: "501" }],
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "all",
|
||||
replyQuoteMessageId: 500,
|
||||
replyQuoteText: "quoted text",
|
||||
});
|
||||
|
||||
@@ -718,17 +805,59 @@ describe("deliverReplies", () => {
|
||||
"123",
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
reply_to_message_id: 500,
|
||||
reply_to_message_id: 501,
|
||||
allow_sending_without_reply: true,
|
||||
}),
|
||||
);
|
||||
expect(sendMessage.mock.calls[0][2]).not.toHaveProperty("reply_parameters");
|
||||
});
|
||||
|
||||
it("omits native quote parameters when reply mode suppresses the reply", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 13,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "Hello there", replyToId: "500" }],
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "off",
|
||||
replyQuoteMessageId: 500,
|
||||
replyQuoteText: "quoted text",
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls[0][2]).not.toHaveProperty("reply_parameters");
|
||||
expect(sendMessage.mock.calls[0][2]).not.toHaveProperty("reply_to_message_id");
|
||||
});
|
||||
|
||||
it("uses legacy reply id when quote text has no quoted message id", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 12,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "Hello there", replyToId: "501" }],
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "all",
|
||||
replyQuoteText: "quoted text",
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.any(String),
|
||||
expect.not.objectContaining({
|
||||
reply_parameters: expect.anything(),
|
||||
expect.objectContaining({
|
||||
reply_to_message_id: 501,
|
||||
allow_sending_without_reply: true,
|
||||
}),
|
||||
);
|
||||
expect(sendMessage.mock.calls[0][2]).not.toHaveProperty("reply_parameters");
|
||||
});
|
||||
|
||||
it("falls back to text when sendVoice fails with VOICE_MESSAGES_FORBIDDEN", async () => {
|
||||
@@ -819,6 +948,7 @@ describe("deliverReplies", () => {
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "first",
|
||||
replyQuoteMessageId: 77,
|
||||
replyQuoteText: "quoted context",
|
||||
textLimit: 12,
|
||||
});
|
||||
@@ -827,8 +957,11 @@ describe("deliverReplies", () => {
|
||||
expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(sendMessage.mock.calls[0][2]).toEqual(
|
||||
expect.objectContaining({
|
||||
reply_to_message_id: 77,
|
||||
allow_sending_without_reply: true,
|
||||
reply_parameters: {
|
||||
message_id: 77,
|
||||
quote: "quoted context",
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "Ack", callback_data: "ack" }]],
|
||||
},
|
||||
|
||||
@@ -341,6 +341,7 @@ describe("describeReplyTarget", () => {
|
||||
expect(result?.sender).toBe("Alice");
|
||||
expect(result?.id).toBe("1");
|
||||
expect(result?.kind).toBe("reply");
|
||||
expect(result?.source).toBe("reply_to_message");
|
||||
});
|
||||
|
||||
it("handles non-string reply text gracefully (issue #27201)", () => {
|
||||
@@ -502,6 +503,34 @@ describe("describeReplyTarget", () => {
|
||||
expect(result?.forwardedFrom?.fromMessageId).toBe(456);
|
||||
});
|
||||
|
||||
it("marks top-level quote metadata on external replies as external targets", () => {
|
||||
const result = describeReplyTarget({
|
||||
message_id: 5,
|
||||
date: 1300,
|
||||
chat: { id: 1, type: "private" },
|
||||
text: "Comment on forwarded message",
|
||||
quote: {
|
||||
text: "quoted slice",
|
||||
position: 4,
|
||||
entities: [{ type: "italic", offset: 0, length: 6 }],
|
||||
},
|
||||
external_reply: {
|
||||
message_id: 4,
|
||||
date: 1200,
|
||||
chat: { id: 1, type: "private" },
|
||||
text: "Forwarded from elsewhere",
|
||||
from: { id: 123, first_name: "Eve", is_bot: false },
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result?.id).toBe("4");
|
||||
expect(result?.kind).toBe("quote");
|
||||
expect(result?.source).toBe("external_reply");
|
||||
expect(result?.quoteText).toBe("quoted slice");
|
||||
expect(result?.quotePosition).toBe(4);
|
||||
expect(result?.quoteEntities).toEqual([{ type: "italic", offset: 0, length: 6 }]);
|
||||
});
|
||||
|
||||
it("extracts forwarded context from external_reply", () => {
|
||||
const result = describeReplyTarget({
|
||||
message_id: 5,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
resolveTelegramTextContent,
|
||||
resolveTelegramMediaPlaceholder,
|
||||
type TelegramForwardedContext,
|
||||
type TelegramTextEntity,
|
||||
} from "./body-helpers.js";
|
||||
import type { TelegramGetChat, TelegramStreamMode } from "./types.js";
|
||||
|
||||
@@ -375,6 +376,10 @@ export type TelegramReplyTarget = {
|
||||
senderUsername?: string;
|
||||
body?: string;
|
||||
kind: "reply" | "quote";
|
||||
source: "reply_to_message" | "external_reply";
|
||||
quoteText?: string;
|
||||
quotePosition?: number;
|
||||
quoteEntities?: TelegramTextEntity[];
|
||||
/** Forward context if the reply target was itself a forwarded message (issue #9619). */
|
||||
forwardedFrom?: TelegramForwardedContext;
|
||||
};
|
||||
@@ -382,9 +387,9 @@ export type TelegramReplyTarget = {
|
||||
export function describeReplyTarget(msg: Message): TelegramReplyTarget | null {
|
||||
const reply = msg.reply_to_message;
|
||||
const externalReply = (msg as Message & { external_reply?: Message }).external_reply;
|
||||
const rawQuoteText =
|
||||
msg.quote?.text ??
|
||||
(externalReply as (Message & { quote?: { text?: string } }) | undefined)?.quote?.text;
|
||||
const quote =
|
||||
msg.quote ?? (externalReply as (Message & { quote?: Message["quote"] }) | undefined)?.quote;
|
||||
const rawQuoteText = quote?.text;
|
||||
const quoteText = resolveTelegramTextContent(rawQuoteText);
|
||||
let body = "";
|
||||
let kind: TelegramReplyTarget["kind"] = "reply";
|
||||
@@ -425,6 +430,13 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null {
|
||||
}
|
||||
const sender = replyLike ? buildSenderName(replyLike) : undefined;
|
||||
const senderLabel = sender ?? "unknown sender";
|
||||
const source = reply ? "reply_to_message" : "external_reply";
|
||||
const quotePosition =
|
||||
kind === "quote" && typeof quote?.position === "number" && Number.isFinite(quote.position)
|
||||
? Math.trunc(quote.position)
|
||||
: undefined;
|
||||
const quoteEntities =
|
||||
kind === "quote" && Array.isArray(quote?.entities) ? quote.entities : undefined;
|
||||
|
||||
// Extract forward context from the resolved reply target (reply_to_message or external_reply).
|
||||
const forwardedFrom = replyLike ? (normalizeForwardedContext(replyLike) ?? undefined) : undefined;
|
||||
@@ -436,6 +448,10 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null {
|
||||
senderUsername: replyLike?.from?.username ?? undefined,
|
||||
body: body || undefined,
|
||||
kind,
|
||||
source,
|
||||
quoteText: kind === "quote" ? quoteText : undefined,
|
||||
quotePosition,
|
||||
quoteEntities,
|
||||
forwardedFrom,
|
||||
};
|
||||
}
|
||||
|
||||
105
extensions/telegram/src/reply-parameters.test.ts
Normal file
105
extensions/telegram/src/reply-parameters.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildTelegramSendParams,
|
||||
buildTelegramThreadReplyParams,
|
||||
removeTelegramNativeQuoteParam,
|
||||
resolveTelegramSendThreadSpec,
|
||||
} from "./reply-parameters.js";
|
||||
|
||||
describe("telegram reply parameters", () => {
|
||||
it("preserves exact quote text and quote metadata for native Telegram replies", () => {
|
||||
expect(
|
||||
buildTelegramSendParams({
|
||||
replyToMessageId: 42,
|
||||
replyQuoteMessageId: 42,
|
||||
replyQuoteText: " quoted text\n",
|
||||
replyQuotePosition: 12.9,
|
||||
replyQuoteEntities: [{ type: "bold", offset: 1, length: 6 }],
|
||||
thread: { id: 99, scope: "forum" },
|
||||
silent: true,
|
||||
}),
|
||||
).toEqual({
|
||||
message_thread_id: 99,
|
||||
reply_parameters: {
|
||||
message_id: 42,
|
||||
quote: " quoted text\n",
|
||||
quote_position: 12,
|
||||
quote_entities: [{ type: "bold", offset: 1, length: 6 }],
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
disable_notification: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the selected reply id as the quote id when direct sends only provide quote text", () => {
|
||||
expect(
|
||||
buildTelegramThreadReplyParams({
|
||||
replyToMessageId: 77,
|
||||
replyQuoteText: " exact slice ",
|
||||
useReplyIdAsQuoteSource: true,
|
||||
}),
|
||||
).toEqual({
|
||||
reply_parameters: {
|
||||
message_id: 77,
|
||||
quote: " exact slice ",
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to legacy reply id for blank quotes or mismatched quote sources", () => {
|
||||
expect(
|
||||
buildTelegramThreadReplyParams({
|
||||
replyToMessageId: 77,
|
||||
replyQuoteMessageId: 78,
|
||||
replyQuoteText: "quoted",
|
||||
}),
|
||||
).toEqual({
|
||||
reply_to_message_id: 77,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildTelegramThreadReplyParams({
|
||||
replyToMessageId: 77,
|
||||
replyQuoteText: " \n\t",
|
||||
}),
|
||||
).toEqual({
|
||||
reply_to_message_id: 77,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("converts rejected native quote params to legacy reply params for retry", () => {
|
||||
expect(
|
||||
removeTelegramNativeQuoteParam({
|
||||
parse_mode: "HTML",
|
||||
reply_parameters: {
|
||||
message_id: 42,
|
||||
quote: "quoted",
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
parse_mode: "HTML",
|
||||
reply_to_message_id: 42,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps direct-message topic scope for Telegram DM topics", () => {
|
||||
expect(
|
||||
buildTelegramThreadReplyParams({
|
||||
thread: resolveTelegramSendThreadSpec({
|
||||
targetMessageThreadId: 5,
|
||||
chatType: "direct",
|
||||
}),
|
||||
replyToMessageId: 42,
|
||||
}),
|
||||
).toEqual({
|
||||
message_thread_id: 5,
|
||||
reply_to_message_id: 42,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
128
extensions/telegram/src/reply-parameters.ts
Normal file
128
extensions/telegram/src/reply-parameters.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { MessageEntity } from "@grammyjs/types";
|
||||
import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js";
|
||||
import { normalizeTelegramReplyToMessageId } from "./outbound-params.js";
|
||||
|
||||
export type TelegramReplyParameters = {
|
||||
message_id: number;
|
||||
allow_sending_without_reply: true;
|
||||
quote?: string;
|
||||
quote_position?: number;
|
||||
quote_entities?: MessageEntity[];
|
||||
};
|
||||
|
||||
export type TelegramThreadReplyParams = {
|
||||
message_thread_id?: number;
|
||||
reply_parameters?: TelegramReplyParameters;
|
||||
reply_to_message_id?: number;
|
||||
allow_sending_without_reply?: true;
|
||||
};
|
||||
|
||||
export function resolveTelegramSendThreadSpec(params: {
|
||||
targetMessageThreadId?: number;
|
||||
messageThreadId?: number;
|
||||
chatType?: "direct" | "group" | "unknown";
|
||||
}): TelegramThreadSpec | undefined {
|
||||
const messageThreadId =
|
||||
params.messageThreadId != null ? params.messageThreadId : params.targetMessageThreadId;
|
||||
if (messageThreadId == null) {
|
||||
return undefined;
|
||||
}
|
||||
// Telegram supports DM topics; keep direct chat thread IDs and rely on
|
||||
// thread-not-found retry fallback when a plain DM rejects them.
|
||||
return {
|
||||
id: messageThreadId,
|
||||
scope: params.chatType === "direct" ? "dm" : "forum",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTelegramThreadReplyParams(opts?: {
|
||||
thread?: TelegramThreadSpec | null;
|
||||
replyToMessageId?: number;
|
||||
replyQuoteMessageId?: number;
|
||||
replyQuoteText?: string;
|
||||
replyQuotePosition?: number;
|
||||
replyQuoteEntities?: unknown[];
|
||||
useReplyIdAsQuoteSource?: boolean;
|
||||
}): TelegramThreadReplyParams {
|
||||
const params: TelegramThreadReplyParams = {};
|
||||
const threadParams = buildTelegramThreadParams(opts?.thread);
|
||||
if (threadParams) {
|
||||
params.message_thread_id = threadParams.message_thread_id;
|
||||
}
|
||||
|
||||
const replyToMessageId = normalizeTelegramReplyToMessageId(opts?.replyToMessageId);
|
||||
if (replyToMessageId == null) {
|
||||
return params;
|
||||
}
|
||||
|
||||
const defaultQuoteMessageId =
|
||||
opts?.useReplyIdAsQuoteSource === true ? replyToMessageId : undefined;
|
||||
const replyQuoteMessageId = normalizeTelegramReplyToMessageId(
|
||||
opts?.replyQuoteMessageId ?? defaultQuoteMessageId,
|
||||
);
|
||||
const replyQuoteTextRaw =
|
||||
replyQuoteMessageId === replyToMessageId ? opts?.replyQuoteText : undefined;
|
||||
const replyQuoteText = replyQuoteTextRaw?.trim() ? replyQuoteTextRaw : undefined;
|
||||
if (!replyQuoteText) {
|
||||
params.reply_to_message_id = replyToMessageId;
|
||||
params.allow_sending_without_reply = true;
|
||||
return params;
|
||||
}
|
||||
|
||||
const replyParameters: TelegramReplyParameters = {
|
||||
message_id: replyToMessageId,
|
||||
quote: replyQuoteText,
|
||||
allow_sending_without_reply: true,
|
||||
};
|
||||
if (typeof opts?.replyQuotePosition === "number" && Number.isFinite(opts.replyQuotePosition)) {
|
||||
replyParameters.quote_position = Math.trunc(opts.replyQuotePosition);
|
||||
}
|
||||
if (Array.isArray(opts?.replyQuoteEntities) && opts.replyQuoteEntities.length > 0) {
|
||||
replyParameters.quote_entities = opts.replyQuoteEntities as MessageEntity[];
|
||||
}
|
||||
params.reply_parameters = replyParameters;
|
||||
return params;
|
||||
}
|
||||
|
||||
export function buildTelegramSendParams(opts?: {
|
||||
replyToMessageId?: number;
|
||||
replyQuoteMessageId?: number;
|
||||
replyQuoteText?: string;
|
||||
replyQuotePosition?: number;
|
||||
replyQuoteEntities?: unknown[];
|
||||
thread?: TelegramThreadSpec | null;
|
||||
silent?: boolean;
|
||||
useReplyIdAsQuoteSource?: boolean;
|
||||
}): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = { ...buildTelegramThreadReplyParams(opts) };
|
||||
if (opts?.silent === true) {
|
||||
params.disable_notification = true;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export function getTelegramNativeQuoteReplyMessageId(
|
||||
params: Record<string, unknown> | undefined,
|
||||
): number | undefined {
|
||||
const replyParameters = params?.reply_parameters;
|
||||
if (!replyParameters || typeof replyParameters !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const messageId = (replyParameters as { message_id?: unknown }).message_id;
|
||||
return typeof messageId === "number" && Number.isFinite(messageId) ? messageId : undefined;
|
||||
}
|
||||
|
||||
export function removeTelegramNativeQuoteParam(
|
||||
params: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (!params) {
|
||||
return {};
|
||||
}
|
||||
const replyMessageId = getTelegramNativeQuoteReplyMessageId(params);
|
||||
const { reply_parameters: _ignored, ...rest } = params;
|
||||
if (replyMessageId != null) {
|
||||
rest.reply_to_message_id = replyMessageId;
|
||||
rest.allow_sending_without_reply = true;
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
@@ -2164,6 +2164,34 @@ describe("shared send behaviors", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses native reply parameters for direct quote sends without trimming the quote", async () => {
|
||||
const chatId = "123";
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 56,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
await sendMessageTelegram(chatId, "reply text", {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
api,
|
||||
replyToMessageId: 100,
|
||||
quoteText: " quoted text\n",
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, "reply text", {
|
||||
parse_mode: "HTML",
|
||||
reply_parameters: {
|
||||
message_id: 100,
|
||||
quote: " quoted text\n",
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("omits invalid reply_to_message_id values before calling Telegram", async () => {
|
||||
const invalidReplyToMessageIds = ["session-meta-id", "123abc", Number.NaN] as const;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeOptionalString, redactSensitiveText } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js";
|
||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||
import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helpers.js";
|
||||
import { buildTypingThreadParams } from "./bot/helpers.js";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
import { splitTelegramCaption } from "./caption.js";
|
||||
import { resolveTelegramFetch } from "./fetch.js";
|
||||
@@ -22,8 +22,11 @@ import {
|
||||
isTelegramRateLimitError,
|
||||
isTelegramServerError,
|
||||
} from "./network-errors.js";
|
||||
import { normalizeTelegramReplyToMessageId } from "./outbound-params.js";
|
||||
import { makeProxyFetch } from "./proxy.js";
|
||||
import {
|
||||
buildTelegramThreadReplyParams,
|
||||
resolveTelegramSendThreadSpec,
|
||||
} from "./reply-parameters.js";
|
||||
import {
|
||||
buildOutboundMediaLoadOptions,
|
||||
getImageMetadata,
|
||||
@@ -57,16 +60,6 @@ type TelegramCreateForumTopicParams = NonNullable<Parameters<TelegramApi["create
|
||||
type TelegramThreadScopedParams = {
|
||||
message_thread_id?: number;
|
||||
};
|
||||
type TelegramReplyParameters = {
|
||||
message_id: number;
|
||||
quote: string;
|
||||
allow_sending_without_reply: true;
|
||||
};
|
||||
type TelegramThreadReplyParams = TelegramThreadScopedParams & {
|
||||
reply_parameters?: TelegramReplyParameters;
|
||||
reply_to_message_id?: number;
|
||||
allow_sending_without_reply?: true;
|
||||
};
|
||||
const InputFileCtor = grammy.InputFile;
|
||||
const MAX_TELEGRAM_PHOTO_DIMENSION_SUM = 10_000;
|
||||
const MAX_TELEGRAM_PHOTO_ASPECT_RATIO = 20;
|
||||
@@ -403,40 +396,6 @@ function isTelegramHtmlParseError(err: unknown): boolean {
|
||||
return PARSE_ERR_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
function buildTelegramThreadReplyParams(params: {
|
||||
targetMessageThreadId?: number;
|
||||
messageThreadId?: number;
|
||||
chatType?: "direct" | "group" | "unknown";
|
||||
replyToMessageId?: number;
|
||||
quoteText?: string;
|
||||
}): TelegramThreadReplyParams {
|
||||
const messageThreadId =
|
||||
params.messageThreadId != null ? params.messageThreadId : params.targetMessageThreadId;
|
||||
const threadScope = params.chatType === "direct" ? ("dm" as const) : ("forum" as const);
|
||||
// Never blanket-strip DM message_thread_id by chat-id sign.
|
||||
// Telegram supports DM topics; stripping silently misroutes topic replies.
|
||||
// Keep thread id and rely on thread-not-found retry fallback for plain DMs.
|
||||
const threadSpec =
|
||||
messageThreadId != null ? { id: messageThreadId, scope: threadScope } : undefined;
|
||||
const threadIdParams = buildTelegramThreadParams(threadSpec);
|
||||
const threadParams: TelegramThreadReplyParams = threadIdParams ? { ...threadIdParams } : {};
|
||||
|
||||
const replyToMessageId = normalizeTelegramReplyToMessageId(params.replyToMessageId);
|
||||
if (replyToMessageId != null) {
|
||||
if (params.quoteText?.trim()) {
|
||||
threadParams.reply_parameters = {
|
||||
message_id: replyToMessageId,
|
||||
quote: params.quoteText.trim(),
|
||||
allow_sending_without_reply: true,
|
||||
};
|
||||
} else {
|
||||
threadParams.reply_to_message_id = replyToMessageId;
|
||||
threadParams.allow_sending_without_reply = true;
|
||||
}
|
||||
}
|
||||
return threadParams;
|
||||
}
|
||||
|
||||
async function withTelegramHtmlParseFallback<T>(params: {
|
||||
label: string;
|
||||
verbose?: boolean;
|
||||
@@ -636,11 +595,14 @@ export async function sendMessageTelegram(
|
||||
const replyMarkup = buildInlineKeyboard(opts.buttons);
|
||||
|
||||
const threadParams = buildTelegramThreadReplyParams({
|
||||
targetMessageThreadId: target.messageThreadId,
|
||||
messageThreadId: opts.messageThreadId,
|
||||
chatType: target.chatType,
|
||||
thread: resolveTelegramSendThreadSpec({
|
||||
targetMessageThreadId: target.messageThreadId,
|
||||
messageThreadId: opts.messageThreadId,
|
||||
chatType: target.chatType,
|
||||
}),
|
||||
replyToMessageId: opts.replyToMessageId,
|
||||
quoteText: opts.quoteText,
|
||||
replyQuoteText: opts.quoteText,
|
||||
useReplyIdAsQuoteSource: true,
|
||||
});
|
||||
const hasThreadParams = Object.keys(threadParams).length > 0;
|
||||
const requestWithDiag = createTelegramNonIdempotentRequestWithDiag({
|
||||
@@ -1498,9 +1460,11 @@ export async function sendStickerTelegram(
|
||||
});
|
||||
|
||||
const threadParams = buildTelegramThreadReplyParams({
|
||||
targetMessageThreadId: target.messageThreadId,
|
||||
messageThreadId: opts.messageThreadId,
|
||||
chatType: target.chatType,
|
||||
thread: resolveTelegramSendThreadSpec({
|
||||
targetMessageThreadId: target.messageThreadId,
|
||||
messageThreadId: opts.messageThreadId,
|
||||
chatType: target.chatType,
|
||||
}),
|
||||
replyToMessageId: opts.replyToMessageId,
|
||||
});
|
||||
const hasThreadParams = Object.keys(threadParams).length > 0;
|
||||
@@ -1584,9 +1548,11 @@ export async function sendPollTelegram(
|
||||
const normalizedPoll = normalizePollInput(poll, { maxOptions: 10 });
|
||||
|
||||
const threadParams = buildTelegramThreadReplyParams({
|
||||
targetMessageThreadId: target.messageThreadId,
|
||||
messageThreadId: opts.messageThreadId,
|
||||
chatType: target.chatType,
|
||||
thread: resolveTelegramSendThreadSpec({
|
||||
targetMessageThreadId: target.messageThreadId,
|
||||
messageThreadId: opts.messageThreadId,
|
||||
chatType: target.chatType,
|
||||
}),
|
||||
replyToMessageId: opts.replyToMessageId,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user