From f2664ca852c07218b8075535e1daaf2efaa76305 Mon Sep 17 00:00:00 2001 From: Vector Date: Wed, 28 Jan 2026 21:47:53 +0000 Subject: [PATCH 1/2] feat(telegram): add editForumTopic action for renaming forum topics Add a new Telegram tool action 'editForumTopic' that allows the agent to rename forum topics (and change their icon) using the Telegram Bot API. Changes: - Add editForumTopicTelegram() function in send.ts with support for both regular topics and the General topic (thread_id=1, uses separate API) - Add 'editForumTopic' action handler in telegram-actions.ts with proper parameter validation and action gating - Add editForumTopic field to TelegramActionConfig type - Add 4 tests covering happy path, gating, validation, and required params The action accepts: - chatId (required): target chat - messageThreadId (required): topic thread ID to edit - name (optional): new topic name - iconCustomEmojiId (optional): new custom emoji icon At least one of name or iconCustomEmojiId must be provided. Closes #3582 --- .gitignore | 1 + src/agents/tools/telegram-actions.test.ts | 77 +++++++++++++++++++++++ src/agents/tools/telegram-actions.ts | 32 ++++++++++ src/config/types.telegram.ts | 2 + src/telegram/send.ts | 64 +++++++++++++++++++ 5 files changed, 176 insertions(+) diff --git a/.gitignore b/.gitignore index 9dc547c9c..91530fe1c 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ USER.md # local tooling .serena/ +package-lock.json diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index 3b78dde20..7ccdcada3 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 515ff8c47..6c5c93305 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -3,6 +3,7 @@ import type { MoltbotConfig } 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") From 6228f3cd877e7a8dc5f01f13e84b141bb5d2a256 Mon Sep 17 00:00:00 2001 From: Vector Date: Wed, 28 Jan 2026 23:16:07 +0000 Subject: [PATCH 2/2] chore: remove unrelated .gitignore change from feature PR --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 91530fe1c..9dc547c9c 100644 --- a/.gitignore +++ b/.gitignore @@ -71,4 +71,3 @@ USER.md # local tooling .serena/ -package-lock.json