diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 17a6c8e72fc..f375e28336b 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -8,6 +8,7 @@ import { } from "../../telegram/inline-buttons.js"; import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js"; import { + createForumTopicTelegram, deleteMessageTelegram, editMessageTelegram, reactMessageTelegram, @@ -339,5 +340,35 @@ export async function handleTelegramAction( return jsonResult({ ok: true, ...stats }); } + if (action === "createForumTopic") { + if (!isActionEnabled("createForumTopic")) { + throw new Error("Telegram createForumTopic is disabled."); + } + const chatId = readStringOrNumberParam(params, "chatId", { + required: true, + }); + const name = readStringParam(params, "name", { required: true }); + const iconColor = readNumberParam(params, "iconColor", { integer: true }); + const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId"); + const token = resolveTelegramToken(cfg, { accountId }).token; + if (!token) { + throw new Error( + "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", + ); + } + const result = await createForumTopicTelegram(chatId ?? "", name, { + token, + accountId: accountId ?? undefined, + iconColor: iconColor ?? undefined, + iconCustomEmojiId: iconCustomEmojiId ?? undefined, + }); + return jsonResult({ + ok: true, + topicId: result.topicId, + name: result.name, + chatId: result.chatId, + }); + } + throw new Error(`Unsupported Telegram action: ${action}`); } diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 459bbf685d8..6d8fdf68edb 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -498,6 +498,33 @@ describe("telegramMessageActions", () => { expect(String(callPayload.messageId)).toBe("456"); expect(callPayload.emoji).toBe("ok"); }); + + it("maps topic-create params into createForumTopic", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await telegramMessageActions.handleAction?.({ + channel: "telegram", + action: "topic-create", + params: { + to: "telegram:group:-1001234567890:topic:271", + name: "Build Updates", + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledWith( + { + action: "createForumTopic", + chatId: "telegram:group:-1001234567890:topic:271", + name: "Build Updates", + iconColor: undefined, + iconCustomEmojiId: undefined, + accountId: undefined, + }, + cfg, + ); + }); }); describe("signalMessageActions", () => { diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index f4c3141ae14..c0be5c5e49c 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -69,6 +69,9 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { actions.add("sticker"); actions.add("sticker-search"); } + if (gate("createForumTopic")) { + actions.add("topic-create"); + } return Array.from(actions); }, supportsButtons: ({ cfg }) => { @@ -199,6 +202,27 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { ); } + if (action === "topic-create") { + const chatId = + readStringOrNumberParam(params, "chatId") ?? + readStringOrNumberParam(params, "channelId") ?? + readStringParam(params, "to", { required: true }); + const name = readStringParam(params, "name", { required: true }); + const iconColor = readNumberParam(params, "iconColor", { integer: true }); + const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId"); + return await handleTelegramAction( + { + action: "createForumTopic", + chatId, + name, + iconColor: iconColor ?? undefined, + iconCustomEmojiId: iconCustomEmojiId ?? undefined, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + throw new Error(`Action ${action} is not supported for provider ${providerId}.`); }, }; diff --git a/src/channels/plugins/message-action-names.ts b/src/channels/plugins/message-action-names.ts index a98bdb06991..ded4e9a5b7e 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -42,6 +42,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "category-create", "category-edit", "category-delete", + "topic-create", "voice-status", "event-list", "event-create", diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index d8e189e756a..81fc41320b0 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -18,6 +18,8 @@ export type TelegramActionConfig = { editMessage?: boolean; /** Enable sticker actions (send and search). */ sticker?: boolean; + /** Enable forum topic creation. */ + createForumTopic?: boolean; }; export type TelegramNetworkConfig = { diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index d2cb9775a4e..d5b6300d1f7 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -47,6 +47,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record { expect(api.sendPoll).not.toHaveBeenCalled(); }); }); + +describe("createForumTopicTelegram", () => { + it("uses base chat id when target includes topic suffix", async () => { + const createForumTopic = vi.fn().mockResolvedValue({ + message_thread_id: 272, + name: "Build Updates", + }); + const api = { createForumTopic } as unknown as { + createForumTopic: typeof createForumTopic; + }; + + const result = await createForumTopicTelegram("telegram:group:-1001234567890:topic:271", "x", { + token: "tok", + api, + }); + + expect(createForumTopic).toHaveBeenCalledWith("-1001234567890", "x", undefined); + expect(result).toEqual({ + topicId: 272, + name: "Build Updates", + chatId: "-1001234567890", + }); + }); + + it("forwards optional icon fields", async () => { + const createForumTopic = vi.fn().mockResolvedValue({ + message_thread_id: 300, + name: "Roadmap", + }); + const api = { createForumTopic } as unknown as { + createForumTopic: typeof createForumTopic; + }; + + await createForumTopicTelegram("-1001234567890", "Roadmap", { + token: "tok", + api, + iconColor: 0x6fb9f0, + iconCustomEmojiId: " 1234567890 ", + }); + + expect(createForumTopic).toHaveBeenCalledWith("-1001234567890", "Roadmap", { + icon_color: 0x6fb9f0, + icon_custom_emoji_id: "1234567890", + }); + }); +}); diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 9efb57486b3..6959f3930ad 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -1077,3 +1077,104 @@ export async function sendPollTelegram( return { messageId, chatId: resolvedChatId, pollId }; } + +// --------------------------------------------------------------------------- +// Forum topic creation +// --------------------------------------------------------------------------- + +type TelegramCreateForumTopicOpts = { + token?: string; + accountId?: string; + api?: Bot["api"]; + verbose?: boolean; + retry?: RetryConfig; + /** Icon color for the topic (must be one of 0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F). */ + iconColor?: number; + /** Custom emoji ID for the topic icon. */ + iconCustomEmojiId?: string; +}; + +export type TelegramCreateForumTopicResult = { + topicId: number; + name: string; + chatId: string; +}; + +/** + * Create a forum topic in a Telegram supergroup. + * Requires the bot to have `can_manage_topics` permission. + * + * @param chatId - Supergroup chat ID + * @param name - Topic name (1-128 characters) + * @param opts - Optional configuration + */ +export async function createForumTopicTelegram( + chatId: string, + name: string, + opts: TelegramCreateForumTopicOpts = {}, +): Promise { + if (!name?.trim()) { + throw new Error("Forum topic name is required"); + } + const trimmedName = name.trim(); + if (trimmedName.length > 128) { + throw new Error("Forum topic name must be 128 characters or fewer"); + } + + const cfg = loadConfig(); + const account = resolveTelegramAccount({ + cfg, + accountId: opts.accountId, + }); + const token = resolveToken(opts.token, account); + // Accept topic-qualified targets (e.g. telegram:group::topic:) + // but createForumTopic must always target the base supergroup chat id. + const target = parseTelegramTarget(chatId); + const normalizedChatId = normalizeChatId(target.chatId); + 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; + }); + + const extra: Record = {}; + if (opts.iconColor != null) { + extra.icon_color = opts.iconColor; + } + if (opts.iconCustomEmojiId?.trim()) { + extra.icon_custom_emoji_id = opts.iconCustomEmojiId.trim(); + } + + const hasExtra = Object.keys(extra).length > 0; + const result = await requestWithDiag( + () => api.createForumTopic(normalizedChatId, trimmedName, hasExtra ? extra : undefined), + "createForumTopic", + ); + + const topicId = result.message_thread_id; + + recordChannelActivity({ + channel: "telegram", + accountId: account.accountId, + direction: "outbound", + }); + + return { + topicId, + name: result.name ?? trimmedName, + chatId: normalizedChatId, + }; +}