diff --git a/CHANGELOG.md b/CHANGELOG.md index 24316eb860d..960ae605204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Providers/OpenRouter: remove conflicting top-level `reasoning_effort` when injecting nested `reasoning.effort`, preventing OpenRouter 400 payload-validation failures for reasoning models. (#24120) thanks @tenequm. - Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc. - Telegram/Polling: scope persisted polling offsets to bot identity and reuse a single awaited runner-stop path on abort/retry, preventing cross-token offset bleed and overlapping pollers during restart/error recovery. (#10850, #11347) Thanks @talhaorak, @anooprdawar, and @vincentkoc. +- Telegram/Reactions: soft-fail reaction action errors (policy/token/emoji/API), accept snake_case `message_id`, and fallback to inbound message-id context when explicit `messageId` is omitted so DM reactions stay stable without regeneration loops. (#20236, #21001) Thanks @PeterShanxin and @vincentkoc. - Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86. - Security/CI: add pre-commit security hook coverage for private-key detection and production dependency auditing, and enforce those checks in CI alongside baseline secret scanning. Thanks @vincentkoc. - Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc. diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 41f059fb6a7..0cc577bb42b 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -49,6 +49,8 @@ export function createOpenClawTools(options?: { currentChannelId?: string; /** Current thread timestamp for auto-threading (Slack). */ currentThreadTs?: string; + /** Current inbound message id for action fallbacks (e.g. Telegram react). */ + currentMessageId?: string | number; /** Reply-to mode for Slack auto-threading. */ replyToMode?: "off" | "first" | "all"; /** Mutable ref to track if a reply was sent (for "first" mode). */ @@ -96,6 +98,7 @@ export function createOpenClawTools(options?: { currentChannelId: options?.currentChannelId, currentChannelProvider: options?.agentChannel, currentThreadTs: options?.currentThreadTs, + currentMessageId: options?.currentMessageId, replyToMode: options?.replyToMode, hasRepliedRef: options?.hasRepliedRef, sandboxRoot: options?.sandboxRoot, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index aa603b171ed..f92b6a375a7 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -582,6 +582,7 @@ export async function runEmbeddedPiAgent( senderIsOwner: params.senderIsOwner, currentChannelId: params.currentChannelId, currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, replyToMode: params.replyToMode, hasRepliedRef: params.hasRepliedRef, sessionFile: params.sessionFile, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index ab9c557f84a..9c636afa4cd 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -391,6 +391,7 @@ export async function runEmbeddedAttempt( modelAuthMode: resolveModelAuthMode(params.model.provider, params.config), currentChannelId: params.currentChannelId, currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, replyToMode: params.replyToMode, hasRepliedRef: params.hasRepliedRef, modelHasVision, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index b5edec514a4..da0e9eae050 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -48,6 +48,8 @@ export type RunEmbeddedPiAgentParams = { currentChannelId?: string; /** Current thread timestamp for auto-threading (Slack). */ currentThreadTs?: string; + /** Current inbound message id for action fallbacks (e.g. Telegram react). */ + currentMessageId?: string | number; /** Reply-to mode for Slack auto-threading. */ replyToMode?: "off" | "first" | "all"; /** Mutable ref to track if a reply was sent (for "first" mode). */ diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index f40226c960c..4b09d9ad993 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -199,6 +199,8 @@ export function createOpenClawCodingTools(options?: { currentChannelId?: string; /** Current thread timestamp for auto-threading (Slack). */ currentThreadTs?: string; + /** Current inbound message id for action fallbacks (e.g. Telegram react). */ + currentMessageId?: string | number; /** Group id for channel-level tool policy resolution. */ groupId?: string | null; /** Group channel label (e.g. #general) for channel-level tool policy resolution. */ @@ -472,6 +474,7 @@ export function createOpenClawCodingTools(options?: { ]), currentChannelId: options?.currentChannelId, currentThreadTs: options?.currentThreadTs, + currentMessageId: options?.currentMessageId, replyToMode: options?.replyToMode, hasRepliedRef: options?.hasRepliedRef, modelHasVision: options?.modelHasVision, diff --git a/src/agents/tools/common.params.test.ts b/src/agents/tools/common.params.test.ts index ba6044ea72b..d93038cd606 100644 --- a/src/agents/tools/common.params.test.ts +++ b/src/agents/tools/common.params.test.ts @@ -35,6 +35,11 @@ describe("readStringOrNumberParam", () => { const params = { chatId: " abc " }; expect(readStringOrNumberParam(params, "chatId")).toBe("abc"); }); + + it("accepts snake_case aliases for camelCase keys", () => { + const params = { chat_id: "123" }; + expect(readStringOrNumberParam(params, "chatId")).toBe("123"); + }); }); describe("readNumberParam", () => { @@ -47,6 +52,11 @@ describe("readNumberParam", () => { const params = { messageId: "42.9" }; expect(readNumberParam(params, "messageId", { integer: true })).toBe(42); }); + + it("accepts snake_case aliases for camelCase keys", () => { + const params = { message_id: "42" }; + expect(readNumberParam(params, "messageId")).toBe(42); + }); }); describe("required parameter validation", () => { diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index 1aea6dd3cfa..d4b3bc9fc3b 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -53,6 +53,24 @@ export function createActionGate>( }; } +function toSnakeCaseKey(key: string): string { + return key + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toLowerCase(); +} + +function readParamRaw(params: Record, key: string): unknown { + if (Object.hasOwn(params, key)) { + return params[key]; + } + const snakeKey = toSnakeCaseKey(key); + if (snakeKey !== key && Object.hasOwn(params, snakeKey)) { + return params[snakeKey]; + } + return undefined; +} + export function readStringParam( params: Record, key: string, @@ -69,7 +87,7 @@ export function readStringParam( options: StringParamOptions = {}, ) { const { required = false, trim = true, label = key, allowEmpty = false } = options; - const raw = params[key]; + const raw = readParamRaw(params, key); if (typeof raw !== "string") { if (required) { throw new ToolInputError(`${label} required`); @@ -92,7 +110,7 @@ export function readStringOrNumberParam( options: { required?: boolean; label?: string } = {}, ): string | undefined { const { required = false, label = key } = options; - const raw = params[key]; + const raw = readParamRaw(params, key); if (typeof raw === "number" && Number.isFinite(raw)) { return String(raw); } @@ -114,7 +132,7 @@ export function readNumberParam( options: { required?: boolean; label?: string; integer?: boolean } = {}, ): number | undefined { const { required = false, label = key, integer = false } = options; - const raw = params[key]; + const raw = readParamRaw(params, key); let value: number | undefined; if (typeof raw === "number" && Number.isFinite(raw)) { value = raw; @@ -152,7 +170,7 @@ export function readStringArrayParam( options: StringParamOptions = {}, ) { const { required = false, label = key } = options; - const raw = params[key]; + const raw = readParamRaw(params, key); if (Array.isArray(raw)) { const values = raw .filter((entry) => typeof entry === "string") diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index d361cc76f34..31b231cf1ed 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -238,7 +238,19 @@ function buildSendSchema(options: { function buildReactionSchema() { return { - messageId: Type.Optional(Type.String()), + messageId: Type.Optional( + Type.String({ + description: + "Target message id for reaction. For Telegram, if omitted, defaults to the current inbound message id when available.", + }), + ), + message_id: Type.Optional( + Type.String({ + // Intentional duplicate alias for tool-schema discoverability in LLMs. + description: + "snake_case alias of messageId. For Telegram, if omitted, defaults to the current inbound message id when available.", + }), + ), emoji: Type.Optional(Type.String()), remove: Type.Optional(Type.Boolean()), targetAuthor: Type.Optional(Type.String()), @@ -425,6 +437,7 @@ type MessageToolOptions = { currentChannelId?: string; currentChannelProvider?: string; currentThreadTs?: string; + currentMessageId?: string | number; replyToMode?: "off" | "first" | "all"; hasRepliedRef?: { value: boolean }; sandboxRoot?: string; @@ -633,17 +646,23 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { clientDisplayName: "agent", mode: GATEWAY_CLIENT_MODES.BACKEND, }; + const hasCurrentMessageId = + typeof options?.currentMessageId === "number" || + (typeof options?.currentMessageId === "string" && + options.currentMessageId.trim().length > 0); const toolContext = options?.currentChannelId || options?.currentChannelProvider || options?.currentThreadTs || + hasCurrentMessageId || options?.replyToMode || options?.hasRepliedRef ? { currentChannelId: options?.currentChannelId, currentChannelProvider: options?.currentChannelProvider, currentThreadTs: options?.currentThreadTs, + currentMessageId: options?.currentMessageId, replyToMode: options?.replyToMode, hasRepliedRef: options?.hasRepliedRef, // Direct tool invocations should not add cross-context decoration. diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index 1fdc09f18e5..ea7fcddcbb5 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -102,6 +102,46 @@ describe("handleTelegramAction", () => { await expectReactionAdded("extensive"); }); + it("accepts snake_case message_id for reactions", async () => { + const cfg = { + channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } }, + } as OpenClawConfig; + await handleTelegramAction( + { + action: "react", + chatId: "123", + message_id: "456", + emoji: "✅", + }, + cfg, + ); + expect(reactMessageTelegram).toHaveBeenCalledWith( + "123", + 456, + "✅", + expect.objectContaining({ token: "tok", remove: false }), + ); + }); + + it("soft-fails when messageId is missing", async () => { + const cfg = { + channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } }, + } as OpenClawConfig; + const result = await handleTelegramAction( + { + action: "react", + chatId: "123", + emoji: "✅", + }, + cfg, + ); + expect(result.details).toMatchObject({ + ok: false, + reason: "missing_message_id", + }); + expect(reactMessageTelegram).not.toHaveBeenCalled(); + }); + it("removes reactions on empty emoji", async () => { const cfg = { channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } }, @@ -177,18 +217,10 @@ describe("handleTelegramAction", () => { ); }); - it.each([ - { - level: "off" as const, - expectedMessage: /Telegram agent reactions disabled.*reactionLevel="off"/, - }, - { - level: "ack" as const, - expectedMessage: /Telegram agent reactions disabled.*reactionLevel="ack"/, - }, - ])("blocks reactions when reactionLevel is $level", async ({ level, expectedMessage }) => { - await expect( - handleTelegramAction( + it.each(["off", "ack"] as const)( + "soft-fails reactions when reactionLevel is %s", + async (level) => { + const result = await handleTelegramAction( { action: "react", chatId: "123", @@ -196,11 +228,15 @@ describe("handleTelegramAction", () => { emoji: "✅", }, reactionConfig(level), - ), - ).rejects.toThrow(expectedMessage); - }); + ); + expect(result.details).toMatchObject({ + ok: false, + reason: "disabled", + }); + }, + ); - it("also respects legacy actions.reactions gating", async () => { + it("soft-fails when reactions are disabled via actions.reactions", async () => { const cfg = { channels: { telegram: { @@ -210,17 +246,19 @@ describe("handleTelegramAction", () => { }, }, } as OpenClawConfig; - await expect( - handleTelegramAction( - { - action: "react", - chatId: "123", - messageId: "456", - emoji: "✅", - }, - cfg, - ), - ).rejects.toThrow(/Telegram reactions are disabled via actions.reactions/); + const result = await handleTelegramAction( + { + action: "react", + chatId: "123", + messageId: "456", + emoji: "✅", + }, + cfg, + ); + expect(result.details).toMatchObject({ + ok: false, + reason: "disabled", + }); }); it("sends a text message", async () => { @@ -634,18 +672,20 @@ describe("handleTelegramAction per-account gating", () => { }, } as OpenClawConfig; - await expect( - handleTelegramAction( - { - action: "react", - chatId: "123", - messageId: 1, - emoji: "👀", - accountId: "media", - }, - cfg, - ), - ).rejects.toThrow(/reactions are disabled via actions.reactions/i); + const result = await handleTelegramAction( + { + action: "react", + chatId: "123", + messageId: 1, + emoji: "👀", + accountId: "media", + }, + cfg, + ); + expect(result.details).toMatchObject({ + ok: false, + reason: "disabled", + }); }); it("allows account to explicitly re-enable top-level disabled reaction gate", async () => { diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 6bcf67784a4..795ac388d05 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -94,42 +94,69 @@ export async function handleTelegramAction( const isActionEnabled = createTelegramActionGate({ cfg, accountId }); if (action === "react") { - // Check reaction level first + // All react failures return soft results (jsonResult with ok:false) instead + // of throwing, because hard tool errors can trigger model re-generation + // loops and duplicate content. const reactionLevelInfo = resolveTelegramReactionLevel({ cfg, accountId: accountId ?? undefined, }); if (!reactionLevelInfo.agentReactionsEnabled) { - throw new Error( - `Telegram agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` + - `Set channels.telegram.reactionLevel to "minimal" or "extensive" to enable.`, - ); + return jsonResult({ + ok: false, + reason: "disabled", + hint: `Telegram agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). Do not retry.`, + }); } - // Also check the existing action gate for backward compatibility if (!isActionEnabled("reactions")) { - throw new Error("Telegram reactions are disabled via actions.reactions."); + return jsonResult({ + ok: false, + reason: "disabled", + hint: "Telegram reactions are disabled via actions.reactions. Do not retry.", + }); } const chatId = readStringOrNumberParam(params, "chatId", { required: true, }); const messageId = readNumberParam(params, "messageId", { - required: true, integer: true, }); + if (typeof messageId !== "number" || !Number.isFinite(messageId) || messageId <= 0) { + return jsonResult({ + ok: false, + reason: "missing_message_id", + hint: "Telegram reaction requires a valid messageId (or inbound context fallback). Do not retry.", + }); + } const { emoji, remove, isEmpty } = readReactionParams(params, { removeErrorMessage: "Emoji is required to remove a Telegram reaction.", }); const token = resolveTelegramToken(cfg, { accountId }).token; if (!token) { - throw new Error( - "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", - ); + return jsonResult({ + ok: false, + reason: "missing_token", + hint: "Telegram bot token missing. Do not retry.", + }); + } + let reactionResult: Awaited>; + try { + reactionResult = await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", { + token, + remove, + accountId: accountId ?? undefined, + }); + } catch (err) { + const isInvalid = String(err).includes("REACTION_INVALID"); + return jsonResult({ + ok: false, + reason: isInvalid ? "REACTION_INVALID" : "error", + emoji, + hint: isInvalid + ? "This emoji is not supported for Telegram reactions. Add it to your reaction disallow list so you do not try it again." + : "Reaction failed. Do not retry.", + }); } - const reactionResult = await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", { - token, - remove, - accountId: accountId ?? undefined, - }); if (!reactionResult.ok) { return jsonResult({ ok: false, diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 3402e8924c0..58cf1951227 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -22,12 +22,17 @@ export function buildThreadingToolContext(params: { hasRepliedRef: { value: boolean } | undefined; }): ChannelThreadingToolContext { const { sessionCtx, config, hasRepliedRef } = params; + const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid; if (!config) { - return {}; + return { + currentMessageId, + }; } const rawProvider = sessionCtx.Provider?.trim().toLowerCase(); if (!rawProvider) { - return {}; + return { + currentMessageId, + }; } const provider = normalizeChannelId(rawProvider) ?? normalizeAnyChannelId(rawProvider); // Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init) @@ -36,6 +41,7 @@ export function buildThreadingToolContext(params: { return { currentChannelId: sessionCtx.To?.trim() || undefined, currentChannelProvider: provider ?? (rawProvider as ChannelId), + currentMessageId, hasRepliedRef, }; } @@ -48,6 +54,7 @@ export function buildThreadingToolContext(params: { From: sessionCtx.From, To: sessionCtx.To, ChatType: sessionCtx.ChatType, + CurrentMessageId: currentMessageId, ReplyToId: sessionCtx.ReplyToId, ThreadLabel: sessionCtx.ThreadLabel, MessageThreadId: sessionCtx.MessageThreadId, @@ -57,6 +64,7 @@ export function buildThreadingToolContext(params: { return { ...context, currentChannelProvider: provider!, // guaranteed non-null since dock exists + currentMessageId: context.currentMessageId ?? currentMessageId, }; } diff --git a/src/channels/dock.test.ts b/src/channels/dock.test.ts index dcd7ecfa7dc..bfb544a3721 100644 --- a/src/channels/dock.test.ts +++ b/src/channels/dock.test.ts @@ -14,7 +14,12 @@ describe("channels dock", () => { const telegramContext = telegramDock?.threading?.buildToolContext?.({ cfg: emptyConfig(), - context: { To: " room-1 ", MessageThreadId: 42, ReplyToId: "fallback" }, + context: { + To: " room-1 ", + MessageThreadId: 42, + ReplyToId: "fallback", + CurrentMessageId: "9001", + }, hasRepliedRef, }); const googleChatContext = googleChatDock?.threading?.buildToolContext?.({ @@ -26,6 +31,7 @@ describe("channels dock", () => { expect(telegramContext).toEqual({ currentChannelId: "room-1", currentThreadTs: "42", + currentMessageId: "9001", hasRepliedRef, }); expect(googleChatContext).toEqual({ @@ -35,6 +41,23 @@ describe("channels dock", () => { }); }); + it("telegram threading does not treat ReplyToId as thread id in DMs", () => { + const hasRepliedRef = { value: false }; + const telegramDock = getChannelDock("telegram"); + const context = telegramDock?.threading?.buildToolContext?.({ + cfg: emptyConfig(), + context: { To: " dm-1 ", ReplyToId: "12345", CurrentMessageId: "12345" }, + hasRepliedRef, + }); + + expect(context).toEqual({ + currentChannelId: "dm-1", + currentThreadTs: undefined, + currentMessageId: "12345", + hasRepliedRef, + }); + }); + it("irc resolveDefaultTo matches account id case-insensitively", () => { const ircDock = getChannelDock("irc"); const cfg = { diff --git a/src/channels/dock.ts b/src/channels/dock.ts index c773aa43cf7..3cec944b800 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -253,8 +253,22 @@ const DOCKS: Record = { }, threading: { resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off", - buildToolContext: ({ context, hasRepliedRef }) => - buildThreadToolContextFromMessageThreadOrReply({ context, hasRepliedRef }), + buildToolContext: ({ context, hasRepliedRef }) => { + // Telegram auto-threading should only use actual thread/topic IDs. + // ReplyToId is a message ID and causes invalid message_thread_id in DMs. + const threadId = context.MessageThreadId; + const rawCurrentMessageId = context.CurrentMessageId; + const currentMessageId = + typeof rawCurrentMessageId === "number" + ? rawCurrentMessageId + : rawCurrentMessageId?.trim() || undefined; + return { + currentChannelId: context.To?.trim() || undefined, + currentThreadTs: threadId != null ? String(threadId) : undefined, + currentMessageId, + hasRepliedRef, + }; + }, }, }, whatsapp: { diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 4fce8fc5b3b..d88e2af49a9 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -673,6 +673,83 @@ describe("telegramMessageActions", () => { expect(String(callPayload.messageId)).toBe("456"); expect(callPayload.emoji).toBe("ok"); }); + + it("accepts snake_case message_id for reactions", async () => { + const cfg = telegramCfg(); + + await telegramMessageActions.handleAction?.({ + channel: "telegram", + action: "react", + params: { + channelId: 123, + message_id: "456", + emoji: "ok", + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledTimes(1); + const call = handleTelegramAction.mock.calls[0]?.[0]; + if (!call) { + throw new Error("missing telegram action call"); + } + const callPayload = call as Record; + expect(callPayload.action).toBe("react"); + expect(String(callPayload.chatId)).toBe("123"); + expect(String(callPayload.messageId)).toBe("456"); + }); + + it("falls back to toolContext.currentMessageId for reactions when messageId is omitted", async () => { + const cfg = telegramCfg(); + + await telegramMessageActions.handleAction?.({ + channel: "telegram", + action: "react", + params: { + chatId: "123", + emoji: "ok", + }, + cfg, + accountId: undefined, + toolContext: { currentMessageId: "9001" }, + }); + + expect(handleTelegramAction).toHaveBeenCalledTimes(1); + const call = handleTelegramAction.mock.calls[0]?.[0]; + if (!call) { + throw new Error("missing telegram action call"); + } + const callPayload = call as Record; + expect(callPayload.action).toBe("react"); + expect(String(callPayload.messageId)).toBe("9001"); + }); + + it("forwards missing reaction messageId to telegram-actions for soft-fail handling", async () => { + const cfg = telegramCfg(); + + await expect( + telegramMessageActions.handleAction?.({ + channel: "telegram", + action: "react", + params: { + chatId: "123", + emoji: "ok", + }, + cfg, + accountId: undefined, + }), + ).resolves.toBeDefined(); + + expect(handleTelegramAction).toHaveBeenCalledTimes(1); + const call = handleTelegramAction.mock.calls[0]?.[0]; + if (!call) { + throw new Error("missing telegram action call"); + } + const callPayload = call as Record; + expect(callPayload.action).toBe("react"); + expect(callPayload.messageId).toBeUndefined(); + }); }); describe("signalMessageActions", () => { diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 7328386848d..537ea2fee3c 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -107,7 +107,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { extractToolSend: ({ args }) => { return extractToolSend(args, "sendMessage"); }, - handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots }) => { + handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, toolContext }) => { if (action === "send") { const sendParams = readTelegramSendParams(params); return await handleTelegramAction( @@ -122,9 +122,8 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { } if (action === "react") { - const messageId = readStringOrNumberParam(params, "messageId", { - required: true, - }); + const messageId = + readStringOrNumberParam(params, "messageId") ?? toolContext?.currentMessageId; const emoji = readStringParam(params, "emoji", { allowEmpty: true }); const remove = typeof params.remove === "boolean" ? params.remove : undefined; return await handleTelegramAction( diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 6b8651e6c85..775fdef649e 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -249,6 +249,7 @@ export type ChannelThreadingContext = { From?: string; To?: string; ChatType?: string; + CurrentMessageId?: string | number; ReplyToId?: string; ReplyToIdFull?: string; ThreadLabel?: string; @@ -259,6 +260,7 @@ export type ChannelThreadingToolContext = { currentChannelId?: string; currentChannelProvider?: ChannelId; currentThreadTs?: string; + currentMessageId?: string | number; replyToMode?: "off" | "first" | "all"; hasRepliedRef?: { value: boolean }; /**