fix(telegram): make reaction handling soft-fail and message-id resilient (#20236)

* Telegram: soft-fail reactions and fallback to inbound message id

* Telegram: soft-fail missing reaction message id

* Update CHANGELOG.md

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
LI SHANXIN
2026-02-23 23:25:14 +08:00
committed by GitHub
parent ea47ab29bd
commit c1b75ab8e2
17 changed files with 317 additions and 69 deletions

View File

@@ -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. - 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. - 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/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. - 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. - 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. - 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.

View File

@@ -49,6 +49,8 @@ export function createOpenClawTools(options?: {
currentChannelId?: string; currentChannelId?: string;
/** Current thread timestamp for auto-threading (Slack). */ /** Current thread timestamp for auto-threading (Slack). */
currentThreadTs?: string; currentThreadTs?: string;
/** Current inbound message id for action fallbacks (e.g. Telegram react). */
currentMessageId?: string | number;
/** Reply-to mode for Slack auto-threading. */ /** Reply-to mode for Slack auto-threading. */
replyToMode?: "off" | "first" | "all"; replyToMode?: "off" | "first" | "all";
/** Mutable ref to track if a reply was sent (for "first" mode). */ /** Mutable ref to track if a reply was sent (for "first" mode). */
@@ -96,6 +98,7 @@ export function createOpenClawTools(options?: {
currentChannelId: options?.currentChannelId, currentChannelId: options?.currentChannelId,
currentChannelProvider: options?.agentChannel, currentChannelProvider: options?.agentChannel,
currentThreadTs: options?.currentThreadTs, currentThreadTs: options?.currentThreadTs,
currentMessageId: options?.currentMessageId,
replyToMode: options?.replyToMode, replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef, hasRepliedRef: options?.hasRepliedRef,
sandboxRoot: options?.sandboxRoot, sandboxRoot: options?.sandboxRoot,

View File

@@ -582,6 +582,7 @@ export async function runEmbeddedPiAgent(
senderIsOwner: params.senderIsOwner, senderIsOwner: params.senderIsOwner,
currentChannelId: params.currentChannelId, currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs, currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
replyToMode: params.replyToMode, replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef, hasRepliedRef: params.hasRepliedRef,
sessionFile: params.sessionFile, sessionFile: params.sessionFile,

View File

@@ -391,6 +391,7 @@ export async function runEmbeddedAttempt(
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config), modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),
currentChannelId: params.currentChannelId, currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs, currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
replyToMode: params.replyToMode, replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef, hasRepliedRef: params.hasRepliedRef,
modelHasVision, modelHasVision,

View File

@@ -48,6 +48,8 @@ export type RunEmbeddedPiAgentParams = {
currentChannelId?: string; currentChannelId?: string;
/** Current thread timestamp for auto-threading (Slack). */ /** Current thread timestamp for auto-threading (Slack). */
currentThreadTs?: string; currentThreadTs?: string;
/** Current inbound message id for action fallbacks (e.g. Telegram react). */
currentMessageId?: string | number;
/** Reply-to mode for Slack auto-threading. */ /** Reply-to mode for Slack auto-threading. */
replyToMode?: "off" | "first" | "all"; replyToMode?: "off" | "first" | "all";
/** Mutable ref to track if a reply was sent (for "first" mode). */ /** Mutable ref to track if a reply was sent (for "first" mode). */

View File

@@ -199,6 +199,8 @@ export function createOpenClawCodingTools(options?: {
currentChannelId?: string; currentChannelId?: string;
/** Current thread timestamp for auto-threading (Slack). */ /** Current thread timestamp for auto-threading (Slack). */
currentThreadTs?: string; currentThreadTs?: string;
/** Current inbound message id for action fallbacks (e.g. Telegram react). */
currentMessageId?: string | number;
/** Group id for channel-level tool policy resolution. */ /** Group id for channel-level tool policy resolution. */
groupId?: string | null; groupId?: string | null;
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */ /** Group channel label (e.g. #general) for channel-level tool policy resolution. */
@@ -472,6 +474,7 @@ export function createOpenClawCodingTools(options?: {
]), ]),
currentChannelId: options?.currentChannelId, currentChannelId: options?.currentChannelId,
currentThreadTs: options?.currentThreadTs, currentThreadTs: options?.currentThreadTs,
currentMessageId: options?.currentMessageId,
replyToMode: options?.replyToMode, replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef, hasRepliedRef: options?.hasRepliedRef,
modelHasVision: options?.modelHasVision, modelHasVision: options?.modelHasVision,

View File

@@ -35,6 +35,11 @@ describe("readStringOrNumberParam", () => {
const params = { chatId: " abc " }; const params = { chatId: " abc " };
expect(readStringOrNumberParam(params, "chatId")).toBe("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", () => { describe("readNumberParam", () => {
@@ -47,6 +52,11 @@ describe("readNumberParam", () => {
const params = { messageId: "42.9" }; const params = { messageId: "42.9" };
expect(readNumberParam(params, "messageId", { integer: true })).toBe(42); 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", () => { describe("required parameter validation", () => {

View File

@@ -53,6 +53,24 @@ export function createActionGate<T extends Record<string, boolean | undefined>>(
}; };
} }
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<string, unknown>, 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( export function readStringParam(
params: Record<string, unknown>, params: Record<string, unknown>,
key: string, key: string,
@@ -69,7 +87,7 @@ export function readStringParam(
options: StringParamOptions = {}, options: StringParamOptions = {},
) { ) {
const { required = false, trim = true, label = key, allowEmpty = false } = options; const { required = false, trim = true, label = key, allowEmpty = false } = options;
const raw = params[key]; const raw = readParamRaw(params, key);
if (typeof raw !== "string") { if (typeof raw !== "string") {
if (required) { if (required) {
throw new ToolInputError(`${label} required`); throw new ToolInputError(`${label} required`);
@@ -92,7 +110,7 @@ export function readStringOrNumberParam(
options: { required?: boolean; label?: string } = {}, options: { required?: boolean; label?: string } = {},
): string | undefined { ): string | undefined {
const { required = false, label = key } = options; const { required = false, label = key } = options;
const raw = params[key]; const raw = readParamRaw(params, key);
if (typeof raw === "number" && Number.isFinite(raw)) { if (typeof raw === "number" && Number.isFinite(raw)) {
return String(raw); return String(raw);
} }
@@ -114,7 +132,7 @@ export function readNumberParam(
options: { required?: boolean; label?: string; integer?: boolean } = {}, options: { required?: boolean; label?: string; integer?: boolean } = {},
): number | undefined { ): number | undefined {
const { required = false, label = key, integer = false } = options; const { required = false, label = key, integer = false } = options;
const raw = params[key]; const raw = readParamRaw(params, key);
let value: number | undefined; let value: number | undefined;
if (typeof raw === "number" && Number.isFinite(raw)) { if (typeof raw === "number" && Number.isFinite(raw)) {
value = raw; value = raw;
@@ -152,7 +170,7 @@ export function readStringArrayParam(
options: StringParamOptions = {}, options: StringParamOptions = {},
) { ) {
const { required = false, label = key } = options; const { required = false, label = key } = options;
const raw = params[key]; const raw = readParamRaw(params, key);
if (Array.isArray(raw)) { if (Array.isArray(raw)) {
const values = raw const values = raw
.filter((entry) => typeof entry === "string") .filter((entry) => typeof entry === "string")

View File

@@ -238,7 +238,19 @@ function buildSendSchema(options: {
function buildReactionSchema() { function buildReactionSchema() {
return { 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()), emoji: Type.Optional(Type.String()),
remove: Type.Optional(Type.Boolean()), remove: Type.Optional(Type.Boolean()),
targetAuthor: Type.Optional(Type.String()), targetAuthor: Type.Optional(Type.String()),
@@ -425,6 +437,7 @@ type MessageToolOptions = {
currentChannelId?: string; currentChannelId?: string;
currentChannelProvider?: string; currentChannelProvider?: string;
currentThreadTs?: string; currentThreadTs?: string;
currentMessageId?: string | number;
replyToMode?: "off" | "first" | "all"; replyToMode?: "off" | "first" | "all";
hasRepliedRef?: { value: boolean }; hasRepliedRef?: { value: boolean };
sandboxRoot?: string; sandboxRoot?: string;
@@ -633,17 +646,23 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
clientDisplayName: "agent", clientDisplayName: "agent",
mode: GATEWAY_CLIENT_MODES.BACKEND, mode: GATEWAY_CLIENT_MODES.BACKEND,
}; };
const hasCurrentMessageId =
typeof options?.currentMessageId === "number" ||
(typeof options?.currentMessageId === "string" &&
options.currentMessageId.trim().length > 0);
const toolContext = const toolContext =
options?.currentChannelId || options?.currentChannelId ||
options?.currentChannelProvider || options?.currentChannelProvider ||
options?.currentThreadTs || options?.currentThreadTs ||
hasCurrentMessageId ||
options?.replyToMode || options?.replyToMode ||
options?.hasRepliedRef options?.hasRepliedRef
? { ? {
currentChannelId: options?.currentChannelId, currentChannelId: options?.currentChannelId,
currentChannelProvider: options?.currentChannelProvider, currentChannelProvider: options?.currentChannelProvider,
currentThreadTs: options?.currentThreadTs, currentThreadTs: options?.currentThreadTs,
currentMessageId: options?.currentMessageId,
replyToMode: options?.replyToMode, replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef, hasRepliedRef: options?.hasRepliedRef,
// Direct tool invocations should not add cross-context decoration. // Direct tool invocations should not add cross-context decoration.

View File

@@ -102,6 +102,46 @@ describe("handleTelegramAction", () => {
await expectReactionAdded("extensive"); 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 () => { it("removes reactions on empty emoji", async () => {
const cfg = { const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } }, channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
@@ -177,18 +217,10 @@ describe("handleTelegramAction", () => {
); );
}); });
it.each([ it.each(["off", "ack"] as const)(
{ "soft-fails reactions when reactionLevel is %s",
level: "off" as const, async (level) => {
expectedMessage: /Telegram agent reactions disabled.*reactionLevel="off"/, const result = await handleTelegramAction(
},
{
level: "ack" as const,
expectedMessage: /Telegram agent reactions disabled.*reactionLevel="ack"/,
},
])("blocks reactions when reactionLevel is $level", async ({ level, expectedMessage }) => {
await expect(
handleTelegramAction(
{ {
action: "react", action: "react",
chatId: "123", chatId: "123",
@@ -196,11 +228,15 @@ describe("handleTelegramAction", () => {
emoji: "✅", emoji: "✅",
}, },
reactionConfig(level), 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 = { const cfg = {
channels: { channels: {
telegram: { telegram: {
@@ -210,17 +246,19 @@ describe("handleTelegramAction", () => {
}, },
}, },
} as OpenClawConfig; } as OpenClawConfig;
await expect( const result = await handleTelegramAction(
handleTelegramAction( {
{ action: "react",
action: "react", chatId: "123",
chatId: "123", messageId: "456",
messageId: "456", emoji: "",
emoji: "✅", },
}, cfg,
cfg, );
), expect(result.details).toMatchObject({
).rejects.toThrow(/Telegram reactions are disabled via actions.reactions/); ok: false,
reason: "disabled",
});
}); });
it("sends a text message", async () => { it("sends a text message", async () => {
@@ -634,18 +672,20 @@ describe("handleTelegramAction per-account gating", () => {
}, },
} as OpenClawConfig; } as OpenClawConfig;
await expect( const result = await handleTelegramAction(
handleTelegramAction( {
{ action: "react",
action: "react", chatId: "123",
chatId: "123", messageId: 1,
messageId: 1, emoji: "👀",
emoji: "👀", accountId: "media",
accountId: "media", },
}, cfg,
cfg, );
), expect(result.details).toMatchObject({
).rejects.toThrow(/reactions are disabled via actions.reactions/i); ok: false,
reason: "disabled",
});
}); });
it("allows account to explicitly re-enable top-level disabled reaction gate", async () => { it("allows account to explicitly re-enable top-level disabled reaction gate", async () => {

View File

@@ -94,42 +94,69 @@ export async function handleTelegramAction(
const isActionEnabled = createTelegramActionGate({ cfg, accountId }); const isActionEnabled = createTelegramActionGate({ cfg, accountId });
if (action === "react") { 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({ const reactionLevelInfo = resolveTelegramReactionLevel({
cfg, cfg,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
}); });
if (!reactionLevelInfo.agentReactionsEnabled) { if (!reactionLevelInfo.agentReactionsEnabled) {
throw new Error( return jsonResult({
`Telegram agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` + ok: false,
`Set channels.telegram.reactionLevel to "minimal" or "extensive" to enable.`, 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")) { 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", { const chatId = readStringOrNumberParam(params, "chatId", {
required: true, required: true,
}); });
const messageId = readNumberParam(params, "messageId", { const messageId = readNumberParam(params, "messageId", {
required: true,
integer: 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, { const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a Telegram reaction.", removeErrorMessage: "Emoji is required to remove a Telegram reaction.",
}); });
const token = resolveTelegramToken(cfg, { accountId }).token; const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) { if (!token) {
throw new Error( return jsonResult({
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ok: false,
); reason: "missing_token",
hint: "Telegram bot token missing. Do not retry.",
});
}
let reactionResult: Awaited<ReturnType<typeof reactMessageTelegram>>;
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) { if (!reactionResult.ok) {
return jsonResult({ return jsonResult({
ok: false, ok: false,

View File

@@ -22,12 +22,17 @@ export function buildThreadingToolContext(params: {
hasRepliedRef: { value: boolean } | undefined; hasRepliedRef: { value: boolean } | undefined;
}): ChannelThreadingToolContext { }): ChannelThreadingToolContext {
const { sessionCtx, config, hasRepliedRef } = params; const { sessionCtx, config, hasRepliedRef } = params;
const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid;
if (!config) { if (!config) {
return {}; return {
currentMessageId,
};
} }
const rawProvider = sessionCtx.Provider?.trim().toLowerCase(); const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
if (!rawProvider) { if (!rawProvider) {
return {}; return {
currentMessageId,
};
} }
const provider = normalizeChannelId(rawProvider) ?? normalizeAnyChannelId(rawProvider); const provider = normalizeChannelId(rawProvider) ?? normalizeAnyChannelId(rawProvider);
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init) // Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
@@ -36,6 +41,7 @@ export function buildThreadingToolContext(params: {
return { return {
currentChannelId: sessionCtx.To?.trim() || undefined, currentChannelId: sessionCtx.To?.trim() || undefined,
currentChannelProvider: provider ?? (rawProvider as ChannelId), currentChannelProvider: provider ?? (rawProvider as ChannelId),
currentMessageId,
hasRepliedRef, hasRepliedRef,
}; };
} }
@@ -48,6 +54,7 @@ export function buildThreadingToolContext(params: {
From: sessionCtx.From, From: sessionCtx.From,
To: sessionCtx.To, To: sessionCtx.To,
ChatType: sessionCtx.ChatType, ChatType: sessionCtx.ChatType,
CurrentMessageId: currentMessageId,
ReplyToId: sessionCtx.ReplyToId, ReplyToId: sessionCtx.ReplyToId,
ThreadLabel: sessionCtx.ThreadLabel, ThreadLabel: sessionCtx.ThreadLabel,
MessageThreadId: sessionCtx.MessageThreadId, MessageThreadId: sessionCtx.MessageThreadId,
@@ -57,6 +64,7 @@ export function buildThreadingToolContext(params: {
return { return {
...context, ...context,
currentChannelProvider: provider!, // guaranteed non-null since dock exists currentChannelProvider: provider!, // guaranteed non-null since dock exists
currentMessageId: context.currentMessageId ?? currentMessageId,
}; };
} }

View File

@@ -14,7 +14,12 @@ describe("channels dock", () => {
const telegramContext = telegramDock?.threading?.buildToolContext?.({ const telegramContext = telegramDock?.threading?.buildToolContext?.({
cfg: emptyConfig(), cfg: emptyConfig(),
context: { To: " room-1 ", MessageThreadId: 42, ReplyToId: "fallback" }, context: {
To: " room-1 ",
MessageThreadId: 42,
ReplyToId: "fallback",
CurrentMessageId: "9001",
},
hasRepliedRef, hasRepliedRef,
}); });
const googleChatContext = googleChatDock?.threading?.buildToolContext?.({ const googleChatContext = googleChatDock?.threading?.buildToolContext?.({
@@ -26,6 +31,7 @@ describe("channels dock", () => {
expect(telegramContext).toEqual({ expect(telegramContext).toEqual({
currentChannelId: "room-1", currentChannelId: "room-1",
currentThreadTs: "42", currentThreadTs: "42",
currentMessageId: "9001",
hasRepliedRef, hasRepliedRef,
}); });
expect(googleChatContext).toEqual({ 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", () => { it("irc resolveDefaultTo matches account id case-insensitively", () => {
const ircDock = getChannelDock("irc"); const ircDock = getChannelDock("irc");
const cfg = { const cfg = {

View File

@@ -253,8 +253,22 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
}, },
threading: { threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off", resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off",
buildToolContext: ({ context, hasRepliedRef }) => buildToolContext: ({ context, hasRepliedRef }) => {
buildThreadToolContextFromMessageThreadOrReply({ 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: { whatsapp: {

View File

@@ -673,6 +673,83 @@ describe("telegramMessageActions", () => {
expect(String(callPayload.messageId)).toBe("456"); expect(String(callPayload.messageId)).toBe("456");
expect(callPayload.emoji).toBe("ok"); 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(callPayload.action).toBe("react");
expect(callPayload.messageId).toBeUndefined();
});
}); });
describe("signalMessageActions", () => { describe("signalMessageActions", () => {

View File

@@ -107,7 +107,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
extractToolSend: ({ args }) => { extractToolSend: ({ args }) => {
return extractToolSend(args, "sendMessage"); return extractToolSend(args, "sendMessage");
}, },
handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots }) => { handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, toolContext }) => {
if (action === "send") { if (action === "send") {
const sendParams = readTelegramSendParams(params); const sendParams = readTelegramSendParams(params);
return await handleTelegramAction( return await handleTelegramAction(
@@ -122,9 +122,8 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
} }
if (action === "react") { if (action === "react") {
const messageId = readStringOrNumberParam(params, "messageId", { const messageId =
required: true, readStringOrNumberParam(params, "messageId") ?? toolContext?.currentMessageId;
});
const emoji = readStringParam(params, "emoji", { allowEmpty: true }); const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined; const remove = typeof params.remove === "boolean" ? params.remove : undefined;
return await handleTelegramAction( return await handleTelegramAction(

View File

@@ -249,6 +249,7 @@ export type ChannelThreadingContext = {
From?: string; From?: string;
To?: string; To?: string;
ChatType?: string; ChatType?: string;
CurrentMessageId?: string | number;
ReplyToId?: string; ReplyToId?: string;
ReplyToIdFull?: string; ReplyToIdFull?: string;
ThreadLabel?: string; ThreadLabel?: string;
@@ -259,6 +260,7 @@ export type ChannelThreadingToolContext = {
currentChannelId?: string; currentChannelId?: string;
currentChannelProvider?: ChannelId; currentChannelProvider?: ChannelId;
currentThreadTs?: string; currentThreadTs?: string;
currentMessageId?: string | number;
replyToMode?: "off" | "first" | "all"; replyToMode?: "off" | "first" | "all";
hasRepliedRef?: { value: boolean }; hasRepliedRef?: { value: boolean };
/** /**