fix(telegram): validate replyToMessageId before sending to Telegram API (#56587)

Add shared normalizeTelegramReplyToMessageId() that rejects non-numeric,
NaN, and mixed-content strings before they reach the Telegram Bot API.
Apply at all four API sinks: direct send, bot delivery, draft stream,
and bot helpers.

Prevents GrammyError 400 when non-numeric values from session metadata
slip through typed boundaries.

Fixes #37222
This commit is contained in:
Robin Waslander
2026-03-28 20:47:10 +01:00
committed by GitHub
parent e69ea1acb3
commit 865160e572
6 changed files with 72 additions and 22 deletions

View File

@@ -3,6 +3,7 @@ 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 { buildInlineKeyboard } from "../send.js";
import { buildTelegramThreadParams, type TelegramThreadSpec } from "./helpers.js";
@@ -82,8 +83,9 @@ export function buildTelegramSendParams(opts?: {
}): Record<string, unknown> {
const threadParams = buildTelegramThreadParams(opts?.thread);
const params: Record<string, unknown> = {};
if (opts?.replyToMessageId) {
params.reply_to_message_id = opts.replyToMessageId;
const replyToMessageId = normalizeTelegramReplyToMessageId(opts?.replyToMessageId);
if (replyToMessageId != null) {
params.reply_to_message_id = replyToMessageId;
params.allow_sending_without_reply = true;
}
if (threadParams) {

View File

@@ -9,6 +9,7 @@ import type {
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { firstDefined, normalizeAllowFrom, type NormalizedAllowFrom } from "../bot-access.js";
import { normalizeTelegramReplyToMessageId } from "../outbound-params.js";
import type { TelegramGetChat, TelegramStreamMode } from "./types.js";
const TELEGRAM_GENERAL_TOPIC_ID = 1;
@@ -415,14 +416,7 @@ export function expandTextLinks(text: string, entities?: TelegramTextLinkEntity[
}
export function resolveTelegramReplyId(raw?: string): number | undefined {
if (!raw) {
return undefined;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
return undefined;
}
return parsed;
return normalizeTelegramReplyToMessageId(raw);
}
export type TelegramReplyTarget = {

View File

@@ -2,6 +2,7 @@ import type { Bot } from "grammy";
import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-lifecycle";
import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js";
import { isSafeToRetrySendError, isTelegramClientRejection } from "./network-errors.js";
import { normalizeTelegramReplyToMessageId } from "./outbound-params.js";
const TELEGRAM_STREAM_MAX_CHARS = 4096;
const DEFAULT_THROTTLE_MS = 1000;
@@ -145,11 +146,12 @@ export function createTelegramDraftStream(params: {
? false
: params.thread?.scope === "dm";
const threadParams = buildTelegramThreadParams(params.thread);
const replyToMessageId = normalizeTelegramReplyToMessageId(params.replyToMessageId);
const replyParams =
params.replyToMessageId != null
replyToMessageId != null
? {
...threadParams,
reply_to_message_id: params.replyToMessageId,
reply_to_message_id: replyToMessageId,
allow_sending_without_reply: true,
}
: threadParams;

View File

@@ -1,11 +1,3 @@
export function parseTelegramReplyToMessageId(replyToId?: string | null): number | undefined {
if (!replyToId) {
return undefined;
}
const parsed = Number.parseInt(replyToId, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function parseIntegerId(value: string): number | undefined {
if (!/^-?\d+$/.test(value)) {
return undefined;
@@ -14,6 +6,21 @@ function parseIntegerId(value: string): number | undefined {
return Number.isFinite(parsed) ? parsed : undefined;
}
export function normalizeTelegramReplyToMessageId(value: unknown): number | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? Math.trunc(value) : undefined;
}
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? parseIntegerId(trimmed) : undefined;
}
export function parseTelegramReplyToMessageId(replyToId?: string | null): number | undefined {
return normalizeTelegramReplyToMessageId(replyToId);
}
export function parseTelegramThreadId(threadId?: string | number | null): number | undefined {
if (threadId == null) {
return undefined;

View File

@@ -1919,6 +1919,50 @@ describe("shared send behaviors", () => {
}
});
it("omits invalid reply_to_message_id values before calling Telegram", async () => {
const invalidReplyToMessageIds = ["session-meta-id", "123abc", Number.NaN] as const;
for (const invalidReplyToMessageId of invalidReplyToMessageIds) {
const chatId = "123";
const sendMessage = vi.fn().mockResolvedValue({
message_id: 56,
chat: { id: chatId },
});
const sendSticker = vi.fn().mockResolvedValue({
message_id: 102,
chat: { id: chatId },
});
const api = { sendMessage, sendSticker } as unknown as {
sendMessage: typeof sendMessage;
sendSticker: typeof sendSticker;
};
await sendMessageTelegram(chatId, "reply text", {
token: "tok",
api,
replyToMessageId: invalidReplyToMessageId as unknown as number,
});
await sendStickerTelegram(chatId, "CAACAgIAAxkBAAI...sticker_file_id", {
token: "tok",
api,
replyToMessageId: invalidReplyToMessageId as unknown as number,
});
expect(sendMessage, String(invalidReplyToMessageId)).toHaveBeenCalledWith(
chatId,
"reply text",
{
parse_mode: "HTML",
},
);
expect(sendSticker, String(invalidReplyToMessageId)).toHaveBeenCalledWith(
chatId,
"CAACAgIAAxkBAAI...sticker_file_id",
undefined,
);
}
});
it("wraps chat-not-found with actionable context", async () => {
const cases = [
{

View File

@@ -42,6 +42,7 @@ import {
normalizeTelegramLookupTarget,
parseTelegramTarget,
} from "./targets.js";
import { normalizeTelegramReplyToMessageId } from "./outbound-params.js";
import { resolveTelegramVoiceSend } from "./voice.js";
type TelegramApi = Bot["api"];
@@ -416,8 +417,8 @@ function buildTelegramThreadReplyParams(params: {
const threadIdParams = buildTelegramThreadParams(threadSpec);
const threadParams: TelegramThreadReplyParams = threadIdParams ? { ...threadIdParams } : {};
if (params.replyToMessageId != null) {
const replyToMessageId = Math.trunc(params.replyToMessageId);
const replyToMessageId = normalizeTelegramReplyToMessageId(params.replyToMessageId);
if (replyToMessageId != null) {
if (params.quoteText?.trim()) {
threadParams.reply_parameters = {
message_id: replyToMessageId,