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); +}