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

@@ -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 = {

View File

@@ -253,8 +253,22 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
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: {

View File

@@ -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<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", () => {

View File

@@ -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(

View File

@@ -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 };
/**