From d68139a8b23610be013383315c8fddc6fa60db52 Mon Sep 17 00:00:00 2001 From: Tyler Diaz Date: Wed, 28 Jan 2026 13:36:18 -0800 Subject: [PATCH 1/3] feat(telegram): add createForumTopic support for thread-create action - Add createForumTopicTelegram() to telegram/send.ts - Add createForumTopic action handler in telegram-actions.ts - Register thread-create action in telegram message plugin - Add createForumTopic to TelegramActionConfig type Allows agents to create forum topics in Telegram supergroups via: message({ action: 'thread-create', channel: 'telegram', target: '', threadName: 'Topic Name' }) Requires bot to have 'Manage Topics' admin permission in the group. --- src/agents/tools/telegram-actions.ts | 34 +++++++++ src/channels/plugins/actions/telegram.ts | 21 ++++++ src/config/types.telegram.ts | 2 + src/telegram/send.ts | 89 ++++++++++++++++++++++++ 4 files changed, 146 insertions(+) diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 515ff8c47..e96c384bb 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -2,6 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { MoltbotConfig } from "../../config/config.js"; import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js"; import { + createForumTopicTelegram, deleteMessageTelegram, editMessageTelegram, reactMessageTelegram, @@ -318,5 +319,38 @@ 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, + }); + } + throw new Error(`Unsupported Telegram action: ${action}`); } diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 693e94492..01759c01f 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -52,6 +52,9 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { actions.add("sticker"); actions.add("sticker-search"); } + if (gate("createForumTopic", false)) { + actions.add("thread-create"); + } return Array.from(actions); }, supportsButtons: ({ cfg }) => { @@ -183,6 +186,24 @@ 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, + ); + } + throw new Error(`Action ${action} is not supported for provider ${providerId}.`); }, }; diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 9a96bce45..354ec7d76 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 creating forum topics in supergroups. */ + createForumTopic?: boolean; }; export type TelegramNetworkConfig = { diff --git a/src/telegram/send.ts b/src/telegram/send.ts index e3f3ac30e..2cfc7f698 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -617,6 +617,95 @@ 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; +}; + +/** + * 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": From 26f51b83077df3cd047a6f687524abd59435a917 Mon Sep 17 00:00:00 2001 From: Tyler Diaz Date: Wed, 28 Jan 2026 13:42:20 -0800 Subject: [PATCH 2/3] feat(telegram): add editForumTopic support for thread-edit action - Add editForumTopicTelegram() to telegram/send.ts - Add editForumTopic action handler in telegram-actions.ts - Register thread-edit action in telegram message plugin - Add editForumTopic to TelegramActionConfig type - Add thread-edit to CHANNEL_MESSAGE_ACTION_NAMES Allows agents to rename forum topics and update custom emoji icons via: message({ action: 'thread-edit', channel: 'telegram', target: '', threadId: , threadName: 'New Name' }) --- src/agents/tools/telegram-actions.ts | 34 +++++++++ src/channels/plugins/actions/telegram.ts | 28 ++++++++ src/channels/plugins/message-action-names.ts | 1 + src/config/types.telegram.ts | 2 + src/infra/outbound/message-action-spec.ts | 1 + src/telegram/send.ts | 74 ++++++++++++++++++++ 6 files changed, 140 insertions(+) diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index e96c384bb..0489e5d30 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -4,6 +4,7 @@ import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js"; import { createForumTopicTelegram, deleteMessageTelegram, + editForumTopicTelegram, editMessageTelegram, reactMessageTelegram, sendMessageTelegram, @@ -352,5 +353,38 @@ export async function handleTelegramAction( }); } + 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 01759c01f..797f5efb6 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -55,6 +55,9 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { if (gate("createForumTopic", false)) { actions.add("thread-create"); } + if (gate("editForumTopic", false)) { + actions.add("thread-edit"); + } return Array.from(actions); }, supportsButtons: ({ cfg }) => { @@ -204,6 +207,31 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { ); } + 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 354ec7d76..5fec12831 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -20,6 +20,8 @@ export type TelegramActionConfig = { 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 { + 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 From b05c075c06294bb85d74aa758b8ce3d5cc27cc8c Mon Sep 17 00:00:00 2001 From: Tyler Diaz Date: Wed, 28 Jan 2026 14:48:29 -0800 Subject: [PATCH 3/3] feat(telegram): inject pinned messages into group context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pinned-context.ts with fetchPinnedMessage() and cache logic - Modify bot-message-context.ts to prepend pinned content to combinedBody - 5-minute cache TTL to avoid API spam - Format: [📌 Pinned by X (date)] content [/Pinned] Pinned messages in Telegram groups/topics now persist in agent context even after conversation history compacts. --- src/telegram/bot-message-context.ts | 13 ++++ src/telegram/pinned-context.ts | 101 ++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/telegram/pinned-context.ts diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 832a4413d..d7c670042 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -1,6 +1,7 @@ import type { Bot } from "grammy"; import { resolveAckReaction } from "../agents/identity.js"; +import { getPinnedContextString } from "./pinned-context.js"; import { findModelInCatalog, loadModelCatalog, @@ -541,6 +542,18 @@ export const buildTelegramMessageContext = async ({ }); } + // Inject pinned message context for groups + if (isGroup) { + try { + const pinnedContext = await getPinnedContextString(bot, chatId, resolvedThreadId); + if (pinnedContext) { + combinedBody = `${pinnedContext}\n\n${combinedBody}`; + } + } catch (err) { + logVerbose(`[telegram] Failed to fetch pinned context: ${String(err)}`); + } + } + const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills); const systemPromptParts = [ groupConfig?.systemPrompt?.trim() || null, diff --git a/src/telegram/pinned-context.ts b/src/telegram/pinned-context.ts new file mode 100644 index 000000000..2f1b7a01d --- /dev/null +++ b/src/telegram/pinned-context.ts @@ -0,0 +1,101 @@ +import type { Bot } from "grammy"; +import { logVerbose } from "../globals.js"; + +type PinnedMessageCache = { + text: string; + from?: string; + date?: number; + fetchedAt: number; +}; + +// Cache pinned messages per chat/topic +// Key format: "chatId" or "chatId:topicId" +const pinnedCache = new Map(); + +// 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); +}