diff --git a/CHANGELOG.md b/CHANGELOG.md index af7b0ee55..71f708535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Telegram: remove `@ts-nocheck` from `bot.ts`, fix duplicate `bot.catch` error handler (Grammy overrides), remove dead reaction `message_thread_id` routing, harden sticker cache guard. (#9077) - Onboarding: add Cloudflare AI Gateway provider setup and docs. (#7914) Thanks @roerohan. - Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz. - Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov. diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index a9b493a83..7af14e86e 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -191,7 +191,7 @@ export const dispatchTelegramMessage = async ({ // Handle uncached stickers: get a dedicated vision description before dispatch // This ensures we cache a raw description rather than a conversational response const sticker = ctxPayload.Sticker; - if (sticker?.fileUniqueId && ctxPayload.MediaPath) { + if (sticker?.fileId && sticker.fileUniqueId && ctxPayload.MediaPath) { const agentDir = resolveAgentDir(cfg, route.agentId); const stickerSupportsVision = await resolveStickerVisionSupport(cfg, route.agentId); let description = sticker.cachedDescription ?? null; diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index bc96c5b60..3602f5ea7 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -2876,7 +2876,7 @@ describe("createTelegramBot", () => { expect(enqueueSystemEvent).not.toHaveBeenCalled(); }); - it("uses correct session key for forum group reactions with topic", async () => { + it("routes forum group reactions to the general topic (thread id not available on reactions)", async () => { onSpy.mockReset(); enqueueSystemEvent.mockReset(); @@ -2891,12 +2891,13 @@ describe("createTelegramBot", () => { ctx: Record, ) => Promise; + // MessageReactionUpdated does not include message_thread_id in the Bot API, + // so forum reactions always route to the general topic (1). await handler({ update: { update_id: 505 }, messageReaction: { chat: { id: 5678, type: "supergroup", is_forum: true }, message_id: 100, - message_thread_id: 42, user: { id: 10, first_name: "Bob", username: "bob_user" }, date: 1736380800, old_reaction: [], @@ -2908,7 +2909,7 @@ describe("createTelegramBot", () => { expect(enqueueSystemEvent).toHaveBeenCalledWith( "Telegram reaction added: 🔥 by Bob (@bob_user) on msg 100", expect.objectContaining({ - sessionKey: expect.stringContaining("telegram:group:5678:topic:42"), + sessionKey: expect.stringContaining("telegram:group:5678:topic:1"), contextKey: expect.stringContaining("telegram:reaction:add:5678:100:10"), }), ); @@ -2991,41 +2992,4 @@ describe("createTelegramBot", () => { const sessionKey = enqueueSystemEvent.mock.calls[0][1].sessionKey; expect(sessionKey).not.toContain(":topic:"); }); - it("uses thread session key for dm reactions with topic id", async () => { - onSpy.mockReset(); - enqueueSystemEvent.mockReset(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open", reactionNotifications: "all" }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message_reaction") as ( - ctx: Record, - ) => Promise; - - await handler({ - update: { update_id: 508 }, - messageReaction: { - chat: { id: 1234, type: "private" }, - message_id: 300, - message_thread_id: 42, - user: { id: 12, first_name: "Dana" }, - date: 1736380800, - old_reaction: [], - new_reaction: [{ type: "emoji", emoji: "🔥" }], - }, - }); - - expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); - expect(enqueueSystemEvent).toHaveBeenCalledWith( - "Telegram reaction added: 🔥 by Dana on msg 300", - expect.objectContaining({ - sessionKey: expect.stringContaining(":thread:42"), - contextKey: expect.stringContaining("telegram:reaction:add:1234:300:12"), - }), - ); - }); }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 44cb38176..7144605c6 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -1,8 +1,7 @@ import type { ApiClientOptions } from "grammy"; -// @ts-nocheck import { sequentialize } from "@grammyjs/runner"; import { apiThrottler } from "@grammyjs/transformer-throttler"; -import { type Message, ReactionTypeEmoji } from "@grammyjs/types"; +import { type Message, type UserFromGetMe, ReactionTypeEmoji } from "@grammyjs/types"; import { Bot, webhookCallback } from "grammy"; import type { OpenClawConfig, ReplyToMode } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -28,7 +27,6 @@ import { enqueueSystemEvent } from "../infra/system-events.js"; import { getChildLogger } from "../logging.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; -import { resolveThreadSessionKeys } from "../routing/session-key.js"; import { resolveTelegramAccount } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { registerTelegramHandlers } from "./bot-handlers.js"; @@ -67,6 +65,7 @@ export type TelegramBotOptions = { export function getTelegramSequentialKey(ctx: { chat?: { id?: number }; + me?: UserFromGetMe; message?: Message; update?: { message?: Message; @@ -87,7 +86,7 @@ export function getTelegramSequentialKey(ctx: { ctx.update?.callback_query?.message; const chatId = msg?.chat?.id ?? ctx.chat?.id; const rawText = msg?.text ?? msg?.caption; - const botUsername = (ctx as { me?: { username?: string } }).me?.username; + const botUsername = ctx.me?.username; if ( rawText && isControlCommandMessage(rawText, undefined, botUsername ? { botUsername } : undefined) @@ -99,7 +98,7 @@ export function getTelegramSequentialKey(ctx: { } const isGroup = msg?.chat?.type === "group" || msg?.chat?.type === "supergroup"; const messageThreadId = msg?.message_thread_id; - const isForum = (msg?.chat as { is_forum?: boolean } | undefined)?.is_forum; + const isForum = msg?.chat?.is_forum; const threadId = isGroup ? resolveTelegramForumThreadId({ isForum, messageThreadId }) : messageThreadId; @@ -135,9 +134,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const client: ApiClientOptions | undefined = shouldProvideFetch || timeoutSeconds ? { - ...(shouldProvideFetch && fetchImpl - ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } - : {}), + ...(shouldProvideFetch && fetchImpl ? { fetch: fetchImpl } : {}), ...(timeoutSeconds ? { timeoutSeconds } : {}), } : undefined; @@ -145,14 +142,9 @@ export function createTelegramBot(opts: TelegramBotOptions) { const bot = new Bot(opts.token, client ? { client } : undefined); bot.api.config.use(apiThrottler()); bot.use(sequentialize(getTelegramSequentialKey)); - bot.catch((err) => { - runtime.error?.(danger(`telegram bot error: ${formatUncaughtError(err)}`)); - }); - // Catch all errors from bot middleware to prevent unhandled rejections bot.catch((err) => { - const message = err instanceof Error ? err.message : String(err); - runtime.error?.(danger(`telegram bot error: ${message}`)); + runtime.error?.(danger(`telegram bot error: ${formatUncaughtError(err)}`)); }); const recentUpdates = createTelegramUpdateDedupe(); @@ -203,11 +195,10 @@ export function createTelegramBot(opts: TelegramBotOptions) { ]; } if (value && typeof value === "object") { - const obj = value as object; - if (seen.has(obj)) { + if (seen.has(value)) { return "[Circular]"; } - seen.add(obj); + seen.add(value); } return value; }); @@ -267,9 +258,8 @@ export function createTelegramBot(opts: TelegramBotOptions) { const streamMode = resolveTelegramStreamMode(telegramCfg); let botHasTopicsEnabled: boolean | undefined; const resolveBotTopicsEnabled = async (ctx?: TelegramContext) => { - const fromCtx = ctx?.me as { has_topics_enabled?: boolean } | undefined; - if (typeof fromCtx?.has_topics_enabled === "boolean") { - botHasTopicsEnabled = fromCtx.has_topics_enabled; + if (typeof ctx?.me?.has_topics_enabled === "boolean") { + botHasTopicsEnabled = ctx.me.has_topics_enabled; return botHasTopicsEnabled; } if (typeof botHasTopicsEnabled === "boolean") { @@ -280,11 +270,11 @@ export function createTelegramBot(opts: TelegramBotOptions) { return botHasTopicsEnabled; } try { - const me = (await withTelegramApiErrorLogging({ + const me = await withTelegramApiErrorLogging({ operation: "getMe", runtime, fn: () => bot.api.getMe(), - })) as { has_topics_enabled?: boolean }; + }); botHasTopicsEnabled = Boolean(me?.has_topics_enabled); } catch (err) { logVerbose(`telegram getMe failed: ${String(err)}`); @@ -445,18 +435,14 @@ export function createTelegramBot(opts: TelegramBotOptions) { } senderLabel = senderLabel || "unknown"; - // Extract forum thread info (similar to message processing) - // oxlint-disable-next-line typescript/no-explicit-any - const messageThreadId = (reaction as any).message_thread_id; - // oxlint-disable-next-line typescript/no-explicit-any - const isForum = (reaction.chat as any).is_forum === true; - const resolvedThreadId = resolveTelegramForumThreadId({ - isForum, - messageThreadId, - }); - - // Resolve agent route for session + // Reactions target a specific message_id; the Telegram Bot API does not include + // message_thread_id on MessageReactionUpdated, so we route to the chat-level + // session (forum topic routing is not available for reactions). const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup"; + const isForum = reaction.chat.is_forum === true; + const resolvedThreadId = isForum + ? resolveTelegramForumThreadId({ isForum, messageThreadId: undefined }) + : undefined; const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId); const route = resolveAgentRoute({ cfg, @@ -464,14 +450,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { accountId: account.accountId, peer: { kind: isGroup ? "group" : "dm", id: peerId }, }); - const baseSessionKey = route.sessionKey; - // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums) - const dmThreadId = !isGroup ? messageThreadId : undefined; - const threadKeys = - dmThreadId != null - ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) }) - : null; - const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; + const sessionKey = route.sessionKey; // Enqueue system event for each added reaction for (const r of addedReactions) { diff --git a/src/telegram/bot/types.ts b/src/telegram/bot/types.ts index 3941e1f3b..f5cbb41cc 100644 --- a/src/telegram/bot/types.ts +++ b/src/telegram/bot/types.ts @@ -1,4 +1,4 @@ -import type { Message } from "@grammyjs/types"; +import type { Message, UserFromGetMe } from "@grammyjs/types"; /** App-specific stream mode for Telegram draft streaming. */ export type TelegramStreamMode = "off" | "partial" | "block"; @@ -10,7 +10,7 @@ export type TelegramStreamMode = "off" | "partial" | "block"; */ export type TelegramContext = { message: Message; - me?: { id?: number; username?: string }; + me?: UserFromGetMe; getFile: () => Promise<{ file_path?: string }>; };