diff --git a/extensions/telegram/src/action-runtime.test.ts b/extensions/telegram/src/action-runtime.test.ts index 943c79c9298..c4e91216f0c 100644 --- a/extensions/telegram/src/action-runtime.test.ts +++ b/extensions/telegram/src/action-runtime.test.ts @@ -407,6 +407,24 @@ describe("handleTelegramAction", () => { expect(reactMessageTelegram).not.toHaveBeenCalled(); }); + it("soft-fails fractional reaction message ids", async () => { + const result = await handleTelegramAction( + { + action: "react", + chatId: "123", + messageId: 456.5, + emoji: "✅", + }, + reactionConfig("minimal"), + ); + + expect(resultDetails(result)).toMatchObject({ + ok: false, + reason: "missing_message_id", + }); + expect(reactMessageTelegram).not.toHaveBeenCalled(); + }); + it("removes reactions on empty emoji", async () => { await handleTelegramAction( { @@ -482,6 +500,52 @@ describe("handleTelegramAction", () => { expect(options.messageThreadId).toBe(11); }); + it("treats null primary id aliases as absent", async () => { + await handleTelegramAction( + { + action: "sendSticker", + to: "123", + fileId: "sticker", + replyToMessageId: null, + replyTo: 9, + messageThreadId: null, + threadId: 11, + }, + telegramConfig({ actions: { sticker: true } }), + ); + const call = mockCall(sendStickerTelegram, 0, "sticker null aliases"); + const options = requireRecord(call[2], "sticker null alias options"); + expect(options.replyToMessageId).toBe(9); + expect(options.messageThreadId).toBe(11); + }); + + it("rejects fractional Telegram thread and reply ids before sending", async () => { + await expect( + handleTelegramAction( + { + action: "sendMessage", + to: "123", + content: "hello", + replyToMessageId: 9.5, + }, + telegramConfig(), + ), + ).rejects.toThrow("replyToMessageId must be a positive integer."); + await expect( + handleTelegramAction( + { + action: "sendSticker", + to: "123", + fileId: "sticker", + threadId: 11.5, + }, + telegramConfig({ actions: { sticker: true } }), + ), + ).rejects.toThrow("threadId must be a positive integer."); + expect(sendDurableMessageBatch).not.toHaveBeenCalled(); + expect(sendStickerTelegram).not.toHaveBeenCalled(); + }); + it("removes reactions when remove flag set", async () => { const cfg = reactionConfig("extensive"); await handleTelegramAction( @@ -991,6 +1055,22 @@ describe("handleTelegramAction", () => { expect(details.pollId).toBe("poll-1"); }); + it("rejects fractional poll durations before sending", async () => { + await expect( + handleTelegramAction( + { + action: "poll", + to: "@testchannel", + question: "Ready?", + answers: ["Yes", "No"], + durationSeconds: 60.5, + }, + telegramConfig(), + ), + ).rejects.toThrow("durationSeconds must be a positive integer."); + expect(sendPollTelegram).not.toHaveBeenCalled(); + }); + it("accepts shared poll action aliases", async () => { await handleTelegramAction( { @@ -1467,6 +1547,36 @@ describe("handleTelegramAction", () => { expect(requireRecord(call[2], "delete message options").token).toBe("tok"); }); + it("rejects fractional message ids before mutating messages", async () => { + const cfg = { + channels: { telegram: { botToken: "tok" } }, + } as OpenClawConfig; + + await expect( + handleTelegramAction( + { + action: "deleteMessage", + chatId: "123", + messageId: 456.5, + }, + cfg, + ), + ).rejects.toThrow("messageId must be a positive integer."); + await expect( + handleTelegramAction( + { + action: "editMessage", + chatId: "123", + messageId: 456.5, + content: "updated", + }, + cfg, + ), + ).rejects.toThrow("messageId must be a positive integer."); + expect(deleteMessageTelegram).not.toHaveBeenCalled(); + expect(editMessageTelegram).not.toHaveBeenCalled(); + }); + it("surfaces non-fatal delete warnings", async () => { deleteMessageTelegram.mockResolvedValueOnce({ ok: false, diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts index 0f2d821bff3..42345e00b76 100644 --- a/extensions/telegram/src/action-runtime.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -2,7 +2,7 @@ import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { jsonResult, - readNumberParam, + readPositiveIntegerParam, readReactionParams, readStringArrayParam, readStringOrNumberParam, @@ -97,7 +97,9 @@ type TelegramForumTopicIconColor = (typeof TELEGRAM_FORUM_TOPIC_ICON_COLORS)[num function readTelegramForumTopicIconColor( params: Record, ): TelegramForumTopicIconColor | undefined { - const iconColor = readNumberParam(params, "iconColor", { integer: true }); + const iconColor = readPositiveIntegerParam(params, "iconColor", { + message: "iconColor must be one of Telegram's supported forum topic colors.", + }); if (iconColor == null) { return undefined; } @@ -124,8 +126,12 @@ function readTelegramChatId(params: Record) { function readTelegramThreadId(params: Record) { return ( - readNumberParam(params, "messageThreadId", { integer: true }) ?? - readNumberParam(params, "threadId", { integer: true }) + readPositiveIntegerParam(params, "messageThreadId", { + message: "messageThreadId must be a positive integer.", + }) ?? + readPositiveIntegerParam(params, "threadId", { + message: "threadId must be a positive integer.", + }) ); } @@ -147,8 +153,12 @@ function formatTelegramDeliveryTarget(to: string, messageThreadId?: number | nul function readTelegramReplyToMessageId(params: Record) { return ( - readNumberParam(params, "replyToMessageId", { integer: true }) ?? - readNumberParam(params, "replyTo", { integer: true }) + readPositiveIntegerParam(params, "replyToMessageId", { + message: "replyToMessageId must be a positive integer.", + }) ?? + readPositiveIntegerParam(params, "replyTo", { + message: "replyTo must be a positive integer.", + }) ); } @@ -353,9 +363,19 @@ export async function handleTelegramAction( }); } const chatId = readTelegramChatId(params); - const messageId = - readNumberParam(params, "messageId", { integer: true }) ?? - resolveReactionMessageId({ args: params }); + let explicitMessageId: number | undefined; + try { + explicitMessageId = readPositiveIntegerParam(params, "messageId", { + message: "messageId must be a positive integer.", + }); + } catch { + return jsonResult({ + ok: false, + reason: "missing_message_id", + hint: "Telegram reaction requires a valid messageId (or inbound context fallback). Do not retry.", + }); + } + const messageId = explicitMessageId ?? resolveReactionMessageId({ args: params }); if (typeof messageId !== "number" || !Number.isFinite(messageId) || messageId <= 0) { return jsonResult({ ok: false, @@ -547,16 +567,18 @@ export async function handleTelegramAction( const allowMultiselect = readBooleanParam(params, "allowMultiselect") ?? readBooleanParam(params, "pollMulti"); const durationSeconds = - readNumberParam(params, "durationSeconds", { integer: true }) ?? - readNumberParam(params, "pollDurationSeconds", { - integer: true, - strict: true, + readPositiveIntegerParam(params, "durationSeconds", { + message: "durationSeconds must be a positive integer.", + }) ?? + readPositiveIntegerParam(params, "pollDurationSeconds", { + message: "pollDurationSeconds must be a positive integer.", }); const durationHours = - readNumberParam(params, "durationHours", { integer: true }) ?? - readNumberParam(params, "pollDurationHours", { - integer: true, - strict: true, + readPositiveIntegerParam(params, "durationHours", { + message: "durationHours must be a positive integer.", + }) ?? + readPositiveIntegerParam(params, "pollDurationHours", { + message: "pollDurationHours must be a positive integer.", }); const replyToMessageId = readTelegramReplyToMessageId(params); const messageThreadId = readTelegramThreadId(params); @@ -607,10 +629,12 @@ export async function handleTelegramAction( throw new Error("Telegram deleteMessage is disabled."); } const chatId = readTelegramChatId(params); - const messageId = readNumberParam(params, "messageId", { - required: true, - integer: true, + const messageId = readPositiveIntegerParam(params, "messageId", { + message: "messageId must be a positive integer.", }); + if (messageId === undefined) { + throw new Error("messageId required"); + } const token = resolveTelegramToken(cfg, { accountId }).token; if (!token) { throw new Error( @@ -634,10 +658,12 @@ export async function handleTelegramAction( throw new Error("Telegram editMessage is disabled."); } const chatId = readTelegramChatId(params); - const messageId = readNumberParam(params, "messageId", { - required: true, - integer: true, + const messageId = readPositiveIntegerParam(params, "messageId", { + message: "messageId must be a positive integer.", }); + if (messageId === undefined) { + throw new Error("messageId required"); + } const content = readStringParam(params, "content", { allowEmpty: false }) ?? readStringParam(params, "message", { required: true, allowEmpty: false }); @@ -722,7 +748,10 @@ export async function handleTelegramAction( ); } const query = readStringParam(params, "query", { required: true }); - const limit = readNumberParam(params, "limit", { integer: true }) ?? 5; + const limit = + readPositiveIntegerParam(params, "limit", { + message: "limit must be a positive integer.", + }) ?? 5; const results = telegramActionRuntime.searchStickers(query, limit); return jsonResult({ ok: true, diff --git a/src/agents/tools/common.params.test.ts b/src/agents/tools/common.params.test.ts index b9bc3736059..c797f76d130 100644 --- a/src/agents/tools/common.params.test.ts +++ b/src/agents/tools/common.params.test.ts @@ -94,6 +94,7 @@ describe("readNumberParam", () => { it("throws for invalid present positive integer params", () => { expect(readPositiveIntegerParam({ timeoutMs: "42" }, "timeoutMs")).toBe(42); + expect(readPositiveIntegerParam({ timeoutMs: null }, "timeoutMs")).toBeUndefined(); expect(() => readPositiveIntegerParam({ timeoutMs: "42.5" }, "timeoutMs")).toThrow( "timeoutMs must be a positive integer", ); diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index 67f0ce0e7a0..dfb8a71f1f2 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -218,7 +218,7 @@ export function readPositiveIntegerParam( positiveInteger: true, strict: true, }); - if (value === undefined && readParamRaw(params, key) !== undefined) { + if (value === undefined && readParamRaw(params, key) != null) { throw new ToolInputError(options.message ?? `${key} must be a positive integer`); } if (value !== undefined && options.max !== undefined && value > options.max) { diff --git a/src/plugin-sdk/channel-actions.ts b/src/plugin-sdk/channel-actions.ts index c58a78c4cc5..ffc2e6092ad 100644 --- a/src/plugin-sdk/channel-actions.ts +++ b/src/plugin-sdk/channel-actions.ts @@ -13,6 +13,7 @@ export { jsonResult, parseAvailableTags, readNumberParam, + readPositiveIntegerParam, readReactionParams, readStringArrayParam, readStringOrNumberParam,