diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index bf8e74db4..692e83691 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -2,7 +2,9 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js"; import { + createForumTopicTelegram, deleteMessageTelegram, + editForumTopicTelegram, editMessageTelegram, reactMessageTelegram, sendMessageTelegram, @@ -318,5 +320,71 @@ export async function handleTelegramAction( return jsonResult({ ok: true, ...stats }); } + if (action === "createForumTopic") { + if (!isActionEnabled("createForumTopic", false)) { + throw new Error( + "Telegram createForumTopic is disabled. Set channels.telegram.actions.createForumTopic to true.", + ); + } + 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, + messageThreadId: result.messageThreadId, + name: result.name, + }); + } + + if (action === "editForumTopic") { + if (!isActionEnabled("editForumTopic", false)) { + throw new Error( + "Telegram editForumTopic is disabled. Set channels.telegram.actions.editForumTopic to true.", + ); + } + 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/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 17df9adbc..eb4e2f581 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -52,6 +52,12 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { actions.add("sticker"); actions.add("sticker-search"); } + if (gate("createForumTopic", false)) { + actions.add("thread-create"); + } + if (gate("editForumTopic", false)) { + actions.add("thread-edit"); + } return Array.from(actions); }, supportsButtons: ({ cfg }) => { @@ -185,6 +191,49 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { ); } + if (action === "thread-create") { + const chatId = + readStringOrNumberParam(params, "chatId") ?? + readStringOrNumberParam(params, "channelId") ?? + readStringParam(params, "target") ?? + readStringParam(params, "to", { required: true }); + const name = readStringParam(params, "threadName", { required: true }); + return await handleTelegramAction( + { + action: "createForumTopic", + chatId, + name, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + + if (action === "thread-edit") { + const chatId = + readStringOrNumberParam(params, "chatId") ?? + readStringOrNumberParam(params, "channelId") ?? + readStringParam(params, "target") ?? + readStringParam(params, "to", { required: true }); + const messageThreadId = readNumberParam(params, "threadId", { + required: true, + integer: true, + }); + const name = readStringParam(params, "threadName"); + const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId"); + return await handleTelegramAction( + { + action: "editForumTopic", + chatId, + messageThreadId, + name: name ?? 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 1884cacb0..193282c16 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -21,6 +21,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "list-pins", "permissions", "thread-create", + "thread-edit", "thread-list", "thread-reply", "search", diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 9a96bce45..5fec12831 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -18,6 +18,10 @@ export type TelegramActionConfig = { editMessage?: boolean; /** Enable sticker actions (send and search). */ sticker?: boolean; + /** Enable creating forum topics in supergroups. */ + createForumTopic?: boolean; + /** Enable editing forum topics in supergroups. */ + editForumTopic?: boolean; }; export type TelegramNetworkConfig = { diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index 639e641d0..07d525323 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -26,6 +26,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record(); + +// Cache TTL: 5 minutes +const CACHE_TTL_MS = 5 * 60 * 1000; + +function buildCacheKey(chatId: string | number, topicId?: number): string { + const base = String(chatId); + return topicId != null && topicId !== 1 ? `${base}:${topicId}` : base; +} + +export function invalidatePinnedCache(chatId: string | number, topicId?: number): void { + const key = buildCacheKey(chatId, topicId); + pinnedCache.delete(key); + logVerbose(`[pinned-context] Invalidated cache for ${key}`); +} + +export async function fetchPinnedMessage( + bot: Bot, + chatId: string | number, + topicId?: number, +): Promise { + const key = buildCacheKey(chatId, topicId); + + // Check cache + const cached = pinnedCache.get(key); + if (cached !== undefined) { + const age = Date.now() - (cached?.fetchedAt ?? 0); + if (age < CACHE_TTL_MS) { + logVerbose(`[pinned-context] Cache hit for ${key}`); + return cached; + } + } + + try { + const chat = await bot.api.getChat(chatId); + const pinned = chat.pinned_message; + + if (!pinned) { + pinnedCache.set(key, null); + logVerbose(`[pinned-context] No pinned message for ${key}`); + return null; + } + + // Extract text content + const text = pinned.text ?? pinned.caption ?? ""; + if (!text.trim()) { + pinnedCache.set(key, null); + return null; + } + + const entry: PinnedMessageCache = { + text: text.trim(), + from: pinned.from?.first_name ?? pinned.from?.username, + date: pinned.date, + fetchedAt: Date.now(), + }; + + pinnedCache.set(key, entry); + logVerbose(`[pinned-context] Fetched pinned message for ${key}: "${text.slice(0, 50)}..."`); + return entry; + } catch (err) { + logVerbose(`[pinned-context] Failed to fetch pinned for ${key}: ${String(err)}`); + // Cache the failure briefly to avoid hammering the API + pinnedCache.set(key, null); + return null; + } +} + +export function formatPinnedContext(pinned: PinnedMessageCache): string { + const datePart = pinned.date + ? ` (pinned ${new Date(pinned.date * 1000).toLocaleDateString()})` + : ""; + const fromPart = pinned.from ? ` by ${pinned.from}` : ""; + return `[📌 Pinned${fromPart}${datePart}]\n${pinned.text}\n[/Pinned]`; +} + +/** + * Get formatted pinned message context for injection. + * Returns empty string if no pinned message. + */ +export async function getPinnedContextString( + bot: Bot, + chatId: string | number, + topicId?: number, +): Promise { + const pinned = await fetchPinnedMessage(bot, chatId, topicId); + if (!pinned) return ""; + return formatPinnedContext(pinned); +} diff --git a/src/telegram/send.ts b/src/telegram/send.ts index e3f3ac30e..95a509318 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -617,6 +617,169 @@ export async function editMessageTelegram( return { ok: true, messageId: String(messageId), chatId }; } +type CreateForumTopicOpts = { + token?: string; + accountId?: string; + verbose?: boolean; + api?: Bot["api"]; + retry?: RetryConfig; + /** Color of the topic icon in RGB format (one of Telegram's allowed colors). */ + iconColor?: number; + /** Custom emoji ID for the topic icon. */ + iconCustomEmojiId?: string; +}; + +export type CreateForumTopicResult = { + ok: boolean; + messageThreadId?: number; + name?: string; + iconColor?: number; + iconCustomEmojiId?: string; +}; + +type EditForumTopicOpts = { + token?: string; + accountId?: string; + verbose?: boolean; + api?: Bot["api"]; + retry?: RetryConfig; + /** New name for the topic (1-128 characters). */ + name?: string; + /** Custom emoji ID for the topic icon. */ + iconCustomEmojiId?: string; +}; + +export type EditForumTopicResult = { + ok: boolean; +}; + +/** + * Edit an existing forum topic in a Telegram supergroup. + * @param chatIdInput - Chat ID of the supergroup + * @param messageThreadId - Thread ID of the topic to edit + * @param opts - Configuration including new name and/or icon + */ +export async function editForumTopicTelegram( + chatIdInput: string | number, + messageThreadId: number, + opts: EditForumTopicOpts = {}, +): Promise { + if (!opts.name && !opts.iconCustomEmojiId) { + throw new Error("At least one of name or iconCustomEmojiId is required to edit a forum topic"); + } + + 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, + }); + 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 params: Record = {}; + if (opts.name) { + params.name = opts.name; + } + if (opts.iconCustomEmojiId) { + params.icon_custom_emoji_id = opts.iconCustomEmojiId; + } + + await requestWithDiag( + () => api.editForumTopic(chatId, messageThreadId, params), + "editForumTopic", + ); + + logVerbose(`[telegram] Edited forum topic ${messageThreadId} in chat ${chatId}`); + + return { ok: true }; +} + +/** + * Create a new forum topic in a Telegram supergroup with topics enabled. + * @param chatIdInput - Chat ID of the supergroup + * @param name - Name of the topic (1-128 characters) + * @param opts - Optional configuration + */ +export async function createForumTopicTelegram( + chatIdInput: string | number, + name: string, + opts: CreateForumTopicOpts = {}, +): Promise { + if (!name?.trim()) { + throw new Error("Forum topic name is required"); + } + + 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, + }); + 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 params: Record = {}; + if (opts.iconColor != null) { + params.icon_color = opts.iconColor; + } + if (opts.iconCustomEmojiId) { + params.icon_custom_emoji_id = opts.iconCustomEmojiId; + } + + const res = await requestWithDiag( + () => + Object.keys(params).length > 0 + ? api.createForumTopic(chatId, name, params) + : api.createForumTopic(chatId, name), + "createForumTopic", + ); + + logVerbose( + `[telegram] Created forum topic "${name}" in chat ${chatId}, thread_id=${res?.message_thread_id}`, + ); + + return { + ok: true, + messageThreadId: res?.message_thread_id, + name: res?.name, + iconColor: res?.icon_color, + iconCustomEmojiId: res?.icon_custom_emoji_id, + }; +} + function inferFilename(kind: ReturnType) { switch (kind) { case "image":