diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index 9c1acb97c..f6928b5a1 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -13,6 +13,7 @@ const sendStickerTelegram = vi.fn(async () => ({ chatId: "123", })); const deleteMessageTelegram = vi.fn(async () => ({ ok: true })); +const editForumTopicTelegram = vi.fn(async () => ({ ok: true })); const originalToken = process.env.TELEGRAM_BOT_TOKEN; vi.mock("../../telegram/send.js", () => ({ @@ -20,6 +21,7 @@ vi.mock("../../telegram/send.js", () => ({ sendMessageTelegram: (...args: unknown[]) => sendMessageTelegram(...args), sendStickerTelegram: (...args: unknown[]) => sendStickerTelegram(...args), deleteMessageTelegram: (...args: unknown[]) => deleteMessageTelegram(...args), + editForumTopicTelegram: (...args: unknown[]) => editForumTopicTelegram(...args), })); describe("handleTelegramAction", () => { @@ -28,6 +30,7 @@ describe("handleTelegramAction", () => { sendMessageTelegram.mockClear(); sendStickerTelegram.mockClear(); deleteMessageTelegram.mockClear(); + editForumTopicTelegram.mockClear(); process.env.TELEGRAM_BOT_TOKEN = "tok"; }); @@ -506,6 +509,80 @@ describe("handleTelegramAction", () => { }), ); }); + + it("edits a forum topic name", async () => { + const cfg = { + channels: { telegram: { botToken: "tok" } }, + } as MoltbotConfig; + await handleTelegramAction( + { + action: "editForumTopic", + chatId: "-1001234567890", + messageThreadId: 42, + name: "New Topic Name", + }, + cfg, + ); + expect(editForumTopicTelegram).toHaveBeenCalledWith( + "-1001234567890", + 42, + expect.objectContaining({ + token: "tok", + name: "New Topic Name", + iconCustomEmojiId: undefined, + }), + ); + }); + + it("respects editForumTopic gating", async () => { + const cfg = { + channels: { + telegram: { botToken: "tok", actions: { editForumTopic: false } }, + }, + } as MoltbotConfig; + await expect( + handleTelegramAction( + { + action: "editForumTopic", + chatId: "-1001234567890", + messageThreadId: 42, + name: "New Name", + }, + cfg, + ), + ).rejects.toThrow(/Telegram editForumTopic is disabled/); + }); + + it("requires at least name or iconCustomEmojiId", async () => { + const cfg = { + channels: { telegram: { botToken: "tok" } }, + } as MoltbotConfig; + await expect( + handleTelegramAction( + { + action: "editForumTopic", + chatId: "-1001234567890", + messageThreadId: 42, + }, + cfg, + ), + ).rejects.toThrow(/At least one of name or iconCustomEmojiId is required/); + }); + + it("requires chatId and messageThreadId", async () => { + const cfg = { + channels: { telegram: { botToken: "tok" } }, + } as MoltbotConfig; + await expect( + handleTelegramAction( + { + action: "editForumTopic", + name: "New Name", + }, + cfg, + ), + ).rejects.toThrow(/chatId required/i); + }); }); describe("readTelegramButtons", () => { diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index bf8e74db4..ef2ca94b5 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js"; import { deleteMessageTelegram, + editForumTopicTelegram, editMessageTelegram, reactMessageTelegram, sendMessageTelegram, @@ -318,5 +319,36 @@ export async function handleTelegramAction( return jsonResult({ ok: true, ...stats }); } + if (action === "editForumTopic") { + if (!isActionEnabled("editForumTopic")) { + throw new Error("Telegram editForumTopic is disabled."); + } + const chatId = readStringOrNumberParam(params, "chatId", { + required: true, + }); + const messageThreadId = readNumberParam(params, "messageThreadId", { + required: true, + integer: true, + }); + const name = readStringParam(params, "name"); + const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId"); + if (!name && !iconCustomEmojiId) { + throw new Error("At least one of name or iconCustomEmojiId is required."); + } + const token = resolveTelegramToken(cfg, { accountId }).token; + if (!token) { + throw new Error( + "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", + ); + } + await editForumTopicTelegram(chatId ?? "", messageThreadId ?? 0, { + token, + accountId: accountId ?? undefined, + name: name ?? undefined, + iconCustomEmojiId: iconCustomEmojiId ?? undefined, + }); + return jsonResult({ ok: true }); + } + throw new Error(`Unsupported Telegram action: ${action}`); } diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 9a96bce45..24b70d1b6 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -16,6 +16,8 @@ export type TelegramActionConfig = { sendMessage?: boolean; deleteMessage?: boolean; editMessage?: boolean; + /** Enable editing forum topic name/icon. */ + editForumTopic?: boolean; /** Enable sticker actions (send and search). */ sticker?: boolean; }; diff --git a/src/telegram/send.ts b/src/telegram/send.ts index e3f3ac30e..a3573fc2f 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -630,6 +630,14 @@ function inferFilename(kind: ReturnType) { } } +type TelegramEditForumTopicOpts = { + token?: string; + accountId?: string; + verbose?: boolean; + api?: Bot["api"]; + retry?: RetryConfig; +}; + type TelegramStickerOpts = { token?: string; accountId?: string; @@ -642,6 +650,62 @@ type TelegramStickerOpts = { messageThreadId?: number; }; +export async function editForumTopicTelegram( + chatIdInput: string | number, + messageThreadId: number, + opts: TelegramEditForumTopicOpts & { + name?: string; + iconCustomEmojiId?: string; + } = {}, +): Promise<{ ok: true }> { + const cfg = loadConfig(); + const account = resolveTelegramAccount({ + cfg, + accountId: opts.accountId, + }); + const token = resolveToken(opts.token, account); + const chatId = normalizeChatId(String(chatIdInput)); + const client = resolveTelegramClientOptions(account); + const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; + const request = createTelegramRetryRunner({ + retry: opts.retry, + configRetry: account.config.retry, + verbose: opts.verbose, + shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }), + }); + const logHttpError = createTelegramHttpLogger(cfg); + const requestWithDiag = (fn: () => Promise, label?: string) => + withTelegramApiErrorLogging({ + operation: label ?? "request", + fn: () => request(fn, label), + }).catch((err) => { + logHttpError(label ?? "request", err); + throw err; + }); + + // The General topic (thread_id = 1) uses a different API method + if (messageThreadId === 1) { + if (!opts.name) { + throw new Error("name is required for editing the General forum topic"); + } + await requestWithDiag( + () => api.editGeneralForumTopic(chatId, opts.name), + "editGeneralForumTopic", + ); + } else { + const editParams: { name?: string; icon_custom_emoji_id?: string } = {}; + if (opts.name) editParams.name = opts.name; + if (opts.iconCustomEmojiId) editParams.icon_custom_emoji_id = opts.iconCustomEmojiId; + await requestWithDiag( + () => api.editForumTopic(chatId, messageThreadId, editParams), + "editForumTopic", + ); + } + + logVerbose(`[telegram] Edited forum topic ${messageThreadId} in chat ${chatId}`); + return { ok: true }; +} + /** * Send a sticker to a Telegram chat by file_id. * @param to - Chat ID or username (e.g., "123456789" or "@username")