diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 39f3a2ec3..2d8c472bd 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -383,6 +383,129 @@ For message tool sends, set `asVoice: true` with a voice-compatible audio `media } ``` +## Stickers + +Clawdbot supports receiving and sending Telegram stickers with intelligent caching. + +### Receiving stickers + +When a user sends a sticker, Clawdbot handles it based on the sticker type: + +- **Static stickers (WEBP):** Downloaded and processed through vision. The sticker appears as a `` placeholder in the message content. +- **Animated stickers (TGS):** Skipped (Lottie format not supported for processing). +- **Video stickers (WEBM):** Skipped (video format not supported for processing). + +Template context fields available when receiving stickers: +- `StickerEmoji` — the emoji associated with the sticker +- `StickerSetName` — the name of the sticker set +- `StickerFileId` — the Telegram file ID (used for sending the same sticker back) + +### Sticker cache + +Stickers are processed through the AI's vision capabilities to generate descriptions. Since the same stickers are often sent repeatedly, Clawdbot caches these descriptions to avoid redundant API calls. + +**How it works:** + +1. **First encounter:** The sticker image is sent to the AI for vision analysis. The AI generates a description (e.g., "A cartoon cat waving enthusiastically"). +2. **Cache storage:** The description is saved along with the sticker's file ID, emoji, and set name. +3. **Subsequent encounters:** When the same sticker is seen again, the cached description is used directly. The image is not sent to the AI. + +**Cache location:** `~/.clawdbot/telegram/sticker-cache.json` + +**Cache entry format:** +```json +{ + "fileId": "CAACAgIAAxkBAAI...", + "emoji": "👋", + "setName": "CoolCats", + "description": "A cartoon cat waving enthusiastically", + "addedAt": "2026-01-15T10:30:00.000Z" +} +``` + +**Benefits:** +- Reduces API costs by avoiding repeated vision calls for the same sticker +- Faster response times for cached stickers (no vision processing delay) +- Enables sticker search functionality based on cached descriptions + +The cache is populated automatically as stickers are received. There is no manual cache management required. + +### Sending stickers + +The agent can send and search stickers using the `sticker` and `sticker-search` actions. These are disabled by default and must be enabled in config: + +```json5 +{ + channels: { + telegram: { + actions: { + sticker: true + } + } + } +} +``` + +**Send a sticker:** + +```json5 +{ + "action": "sticker", + "channel": "telegram", + "to": "123456789", + "fileId": "CAACAgIAAxkBAAI..." +} +``` + +Parameters: +- `fileId` (required) — the Telegram file ID of the sticker. Obtain this from `StickerFileId` when receiving a sticker, or from a `sticker-search` result. +- `replyTo` (optional) — message ID to reply to. +- `threadId` (optional) — message thread ID for forum topics. + +**Search for stickers:** + +The agent can search cached stickers by description, emoji, or set name: + +```json5 +{ + "action": "sticker-search", + "channel": "telegram", + "query": "cat waving", + "limit": 5 +} +``` + +Returns matching stickers from the cache: +```json5 +{ + "ok": true, + "count": 2, + "stickers": [ + { + "fileId": "CAACAgIAAxkBAAI...", + "emoji": "👋", + "description": "A cartoon cat waving enthusiastically", + "setName": "CoolCats" + } + ] +} +``` + +The search uses fuzzy matching across description text, emoji characters, and set names. + +**Example with threading:** + +```json5 +{ + "action": "sticker", + "channel": "telegram", + "to": "-1001234567890", + "fileId": "CAACAgIAAxkBAAI...", + "replyTo": 42, + "threadId": 123 +} +``` + ## Streaming (drafts) Telegram can stream **draft bubbles** while the agent is generating a response. Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the @@ -537,6 +660,7 @@ Provider options: - `channels.telegram.actions.reactions`: gate Telegram tool reactions. - `channels.telegram.actions.sendMessage`: gate Telegram tool message sends. - `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes. +- `channels.telegram.actions.sticker`: gate Telegram sticker actions — send and search (default: false). - `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `own` when not set). - `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set). diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 891ab2b45..40a97d874 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -6,7 +6,9 @@ import { editMessageTelegram, reactMessageTelegram, sendMessageTelegram, + sendStickerTelegram, } from "../../telegram/send.js"; +import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js"; import { resolveTelegramToken } from "../../telegram/token.js"; import { resolveTelegramInlineButtonsScope, @@ -255,5 +257,64 @@ export async function handleTelegramAction( }); } + if (action === "sendSticker") { + if (!isActionEnabled("sticker")) { + throw new Error( + "Telegram sticker actions are disabled. Set channels.telegram.actions.sticker to true.", + ); + } + const to = readStringParam(params, "to", { required: true }); + const fileId = readStringParam(params, "fileId", { required: true }); + const replyToMessageId = readNumberParam(params, "replyToMessageId", { + integer: true, + }); + const messageThreadId = readNumberParam(params, "messageThreadId", { + integer: true, + }); + 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 sendStickerTelegram(to, fileId, { + token, + accountId: accountId ?? undefined, + replyToMessageId: replyToMessageId ?? undefined, + messageThreadId: messageThreadId ?? undefined, + }); + return jsonResult({ + ok: true, + messageId: result.messageId, + chatId: result.chatId, + }); + } + + if (action === "searchSticker") { + if (!isActionEnabled("sticker")) { + throw new Error( + "Telegram sticker actions are disabled. Set channels.telegram.actions.sticker to true.", + ); + } + const query = readStringParam(params, "query", { required: true }); + const limit = readNumberParam(params, "limit", { integer: true }) ?? 5; + const results = searchStickers(query, limit); + return jsonResult({ + ok: true, + count: results.length, + stickers: results.map((s) => ({ + fileId: s.fileId, + emoji: s.emoji, + description: s.description, + setName: s.setName, + })), + }); + } + + if (action === "stickerCacheStats") { + const stats = getCacheStats(); + return jsonResult({ ok: true, ...stats }); + } + throw new Error(`Unsupported Telegram action: ${action}`); } diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index dd424ee71..79692a50d 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -1,4 +1,5 @@ import type { ChannelId } from "../channels/plugins/types.js"; +import type { StickerMetadata } from "../telegram/bot/types.js"; import type { InternalMessageChannel } from "../utils/message-channel.js"; import type { CommandArgs } from "./commands-registry.types.js"; import type { @@ -64,6 +65,8 @@ export type MsgContext = { MediaPaths?: string[]; MediaUrls?: string[]; MediaTypes?: string[]; + /** Telegram sticker metadata (emoji, set name, file IDs, cached description). */ + Sticker?: StickerMetadata; OutputDir?: string; OutputBase?: string; /** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */ diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 364707e0a..f8c7dc0fb 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -1,6 +1,7 @@ import { createActionGate, readNumberParam, + readStringArrayParam, readStringOrNumberParam, readStringParam, } from "../../../agents/tools/common.js"; @@ -45,6 +46,10 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { if (gate("reactions")) actions.add("react"); if (gate("deleteMessage")) actions.add("delete"); if (gate("editMessage")) actions.add("edit"); + if (gate("sticker")) { + actions.add("sticker"); + actions.add("sticker-search"); + } return Array.from(actions); }, supportsButtons: ({ cfg }) => { @@ -141,6 +146,41 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { ); } + if (action === "sticker") { + const to = + readStringParam(params, "to") ?? readStringParam(params, "target", { required: true }); + // Accept stickerId (array from shared schema) and use first element as fileId + const stickerIds = readStringArrayParam(params, "stickerId"); + const fileId = stickerIds?.[0] ?? readStringParam(params, "fileId", { required: true }); + const replyToMessageId = readNumberParam(params, "replyTo", { integer: true }); + const messageThreadId = readNumberParam(params, "threadId", { integer: true }); + return await handleTelegramAction( + { + action: "sendSticker", + to, + fileId, + replyToMessageId: replyToMessageId ?? undefined, + messageThreadId: messageThreadId ?? undefined, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + + if (action === "sticker-search") { + const query = readStringParam(params, "query", { required: true }); + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleTelegramAction( + { + action: "searchSticker", + query, + limit: limit ?? 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 c884f6da3..1884cacb0 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -25,6 +25,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "thread-reply", "search", "sticker", + "sticker-search", "member-info", "role-info", "emoji-list", diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 4d476f88e..9a96bce45 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 sticker actions (send and search). */ + sticker?: boolean; }; export type TelegramNetworkConfig = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index fbf6a2173..ed7dda22a 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -128,6 +128,7 @@ export const TelegramAccountSchemaBase = z reactions: z.boolean().optional(), sendMessage: z.boolean().optional(), deleteMessage: z.boolean().optional(), + sticker: z.boolean().optional(), }) .strict() .optional(), diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index c4f712e0f..639e641d0 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -30,6 +30,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record m.msg.caption || m.msg.text); const primaryEntry = captionMsg ?? entry.messages[0]; - const allMedia: Array<{ path: string; contentType?: string }> = []; + const allMedia: Array<{ + path: string; + contentType?: string; + stickerMetadata?: { emoji?: string; setName?: string; fileId?: string }; + }> = []; for (const { ctx } of entry.messages) { const media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch); if (media) { - allMedia.push({ path: media.path, contentType: media.contentType }); + allMedia.push({ + path: media.path, + contentType: media.contentType, + stickerMetadata: media.stickerMetadata, + }); } } @@ -595,7 +603,24 @@ export const registerTelegramHandlers = ({ } throw mediaErr; } - const allMedia = media ? [{ path: media.path, contentType: media.contentType }] : []; + + // Skip sticker-only messages where the sticker was skipped (animated/video) + // These have no media and no text content to process. + const hasText = Boolean((msg.text ?? msg.caption ?? "").trim()); + if (msg.sticker && !media && !hasText) { + logVerbose("telegram: skipping sticker-only message (unsupported sticker type)"); + return; + } + + const allMedia = media + ? [ + { + path: media.path, + contentType: media.contentType, + stickerMetadata: media.stickerMetadata, + }, + ] + : []; const senderId = msg.from?.id ? String(msg.from.id) : ""; const conversationKey = resolvedThreadId != null ? `${chatId}:topic:${resolvedThreadId}` : String(chatId); diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index a054943a2..71ac8a011 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -49,7 +49,17 @@ import { import { upsertTelegramPairingRequest } from "./pairing-store.js"; import type { TelegramContext } from "./bot/types.js"; -type TelegramMediaRef = { path: string; contentType?: string }; +type TelegramMediaRef = { + path: string; + contentType?: string; + stickerMetadata?: { + emoji?: string; + setName?: string; + fileId?: string; + fileUniqueId?: string; + cachedDescription?: string; + }; +}; type TelegramMessageContextOptions = { forceWasMentioned?: boolean; @@ -302,6 +312,18 @@ export const buildTelegramMessageContext = async ({ else if (msg.video) placeholder = ""; else if (msg.audio || msg.voice) placeholder = ""; else if (msg.document) placeholder = ""; + else if (msg.sticker) placeholder = ""; + + // Check if sticker has a cached description - if so, use it instead of sending the image + const cachedStickerDescription = allMedia[0]?.stickerMetadata?.cachedDescription; + const stickerCacheHit = Boolean(cachedStickerDescription); + if (stickerCacheHit) { + // Format cached description with sticker context + const emoji = allMedia[0]?.stickerMetadata?.emoji; + const setName = allMedia[0]?.stickerMetadata?.setName; + const stickerContext = [emoji, setName ? `from "${setName}"` : null].filter(Boolean).join(" "); + placeholder = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${cachedStickerDescription}`; + } const locationData = extractTelegramLocation(msg); const locationText = locationData ? formatLocationText(locationData) : undefined; @@ -525,15 +547,26 @@ export const buildTelegramMessageContext = async ({ ForwardedDate: forwardOrigin?.date ? forwardOrigin.date * 1000 : undefined, Timestamp: msg.date ? msg.date * 1000 : undefined, WasMentioned: isGroup ? effectiveWasMentioned : undefined, - MediaPath: allMedia[0]?.path, - MediaType: allMedia[0]?.contentType, - MediaUrl: allMedia[0]?.path, - MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined, - MediaUrls: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined, - MediaTypes: - allMedia.length > 0 + // Filter out cached stickers from media - their description is already in the message body + MediaPath: stickerCacheHit ? undefined : allMedia[0]?.path, + MediaType: stickerCacheHit ? undefined : allMedia[0]?.contentType, + MediaUrl: stickerCacheHit ? undefined : allMedia[0]?.path, + MediaPaths: stickerCacheHit + ? undefined + : allMedia.length > 0 + ? allMedia.map((m) => m.path) + : undefined, + MediaUrls: stickerCacheHit + ? undefined + : allMedia.length > 0 + ? allMedia.map((m) => m.path) + : undefined, + MediaTypes: stickerCacheHit + ? undefined + : allMedia.length > 0 ? (allMedia.map((m) => m.contentType).filter(Boolean) as string[]) : undefined, + Sticker: allMedia[0]?.stickerMetadata, ...(locationData ? toLocationContext(locationData) : undefined), CommandAuthorized: commandAuthorized, MessageThreadId: resolvedThreadId, diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 334c4c212..e24796d6c 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -12,6 +12,8 @@ import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { deliverReplies } from "./bot/delivery.js"; import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js"; import { createTelegramDraftStream } from "./draft-stream.js"; +import { cacheSticker, describeStickerImage } from "./sticker-cache.js"; +import { resolveAgentDir } from "../agents/agent-scope.js"; export const dispatchTelegramMessage = async ({ context, @@ -128,6 +130,49 @@ export const dispatchTelegramMessage = async ({ }); const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId); + // Handle uncached stickers: get a dedicated vision description before dispatch + // This ensures we cache a raw description rather than a conversational response + const sticker = ctxPayload.Sticker; + if (sticker?.fileUniqueId && !sticker.cachedDescription && ctxPayload.MediaPath) { + const agentDir = resolveAgentDir(cfg, route.agentId); + const description = await describeStickerImage({ + imagePath: ctxPayload.MediaPath, + cfg, + agentDir, + }); + if (description) { + // Format the description with sticker context + const stickerContext = [sticker.emoji, sticker.setName ? `from "${sticker.setName}"` : null] + .filter(Boolean) + .join(" "); + const formattedDesc = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${description}`; + + // Update context to use description instead of image + sticker.cachedDescription = description; + ctxPayload.Body = formattedDesc; + ctxPayload.BodyForAgent = formattedDesc; + // Clear media paths so native vision doesn't process the image again + ctxPayload.MediaPath = undefined; + ctxPayload.MediaType = undefined; + ctxPayload.MediaUrl = undefined; + ctxPayload.MediaPaths = undefined; + ctxPayload.MediaUrls = undefined; + ctxPayload.MediaTypes = undefined; + + // Cache the description for future encounters + cacheSticker({ + fileId: sticker.fileId, + fileUniqueId: sticker.fileUniqueId, + emoji: sticker.emoji, + setName: sticker.setName, + description, + cachedAt: new Date().toISOString(), + receivedFrom: ctxPayload.From, + }); + logVerbose(`telegram: cached sticker description for ${sticker.fileUniqueId}`); + } + } + const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, @@ -139,6 +184,7 @@ export const dispatchTelegramMessage = async ({ await flushDraft(); draftStream?.stop(); } + await deliverReplies({ replies: [payload], chatId: String(chatId), diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts index b6c1ca419..dd75e6798 100644 --- a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts +++ b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts @@ -405,6 +405,202 @@ describe("telegram media groups", () => { ); }); +describe("telegram stickers", () => { + const STICKER_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000; + + it( + "downloads static sticker (WEBP) and includes sticker metadata", + async () => { + const { createTelegramBot } = await import("./bot.js"); + const replyModule = await import("../auto-reply/reply.js"); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + + onSpy.mockReset(); + replySpy.mockReset(); + sendChatActionSpy.mockReset(); + + const runtimeLog = vi.fn(); + const runtimeError = vi.fn(); + createTelegramBot({ + token: "tok", + runtime: { + log: runtimeLog, + error: runtimeError, + exit: () => { + throw new Error("exit"); + }, + }, + }); + const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as ( + ctx: Record, + ) => Promise; + expect(handler).toBeDefined(); + + const fetchSpy = vi.spyOn(globalThis, "fetch" as never).mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + headers: { get: () => "image/webp" }, + arrayBuffer: async () => new Uint8Array([0x52, 0x49, 0x46, 0x46]).buffer, // RIFF header + } as Response); + + await handler({ + message: { + message_id: 100, + chat: { id: 1234, type: "private" }, + sticker: { + file_id: "sticker_file_id_123", + file_unique_id: "sticker_unique_123", + type: "regular", + width: 512, + height: 512, + is_animated: false, + is_video: false, + emoji: "🎉", + set_name: "TestStickerPack", + }, + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ file_path: "stickers/sticker.webp" }), + }); + + expect(runtimeError).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledWith( + "https://api.telegram.org/file/bottok/stickers/sticker.webp", + ); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain(""); + expect(payload.Sticker?.emoji).toBe("🎉"); + expect(payload.Sticker?.setName).toBe("TestStickerPack"); + expect(payload.Sticker?.fileId).toBe("sticker_file_id_123"); + + fetchSpy.mockRestore(); + }, + STICKER_TEST_TIMEOUT_MS, + ); + + it( + "skips animated stickers (TGS format)", + async () => { + const { createTelegramBot } = await import("./bot.js"); + const replyModule = await import("../auto-reply/reply.js"); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + + onSpy.mockReset(); + replySpy.mockReset(); + + const runtimeError = vi.fn(); + const fetchSpy = vi.spyOn(globalThis, "fetch" as never); + + createTelegramBot({ + token: "tok", + runtime: { + log: vi.fn(), + error: runtimeError, + exit: () => { + throw new Error("exit"); + }, + }, + }); + const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as ( + ctx: Record, + ) => Promise; + expect(handler).toBeDefined(); + + await handler({ + message: { + message_id: 101, + chat: { id: 1234, type: "private" }, + sticker: { + file_id: "animated_sticker_id", + file_unique_id: "animated_unique", + type: "regular", + width: 512, + height: 512, + is_animated: true, // TGS format + is_video: false, + emoji: "😎", + set_name: "AnimatedPack", + }, + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ file_path: "stickers/animated.tgs" }), + }); + + // Should not attempt to download animated stickers + expect(fetchSpy).not.toHaveBeenCalled(); + // Should still process the message (as text-only, no media) + expect(replySpy).not.toHaveBeenCalled(); // No text content, so no reply generated + expect(runtimeError).not.toHaveBeenCalled(); + + fetchSpy.mockRestore(); + }, + STICKER_TEST_TIMEOUT_MS, + ); + + it( + "skips video stickers (WEBM format)", + async () => { + const { createTelegramBot } = await import("./bot.js"); + const replyModule = await import("../auto-reply/reply.js"); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + + onSpy.mockReset(); + replySpy.mockReset(); + + const runtimeError = vi.fn(); + const fetchSpy = vi.spyOn(globalThis, "fetch" as never); + + createTelegramBot({ + token: "tok", + runtime: { + log: vi.fn(), + error: runtimeError, + exit: () => { + throw new Error("exit"); + }, + }, + }); + const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as ( + ctx: Record, + ) => Promise; + expect(handler).toBeDefined(); + + await handler({ + message: { + message_id: 102, + chat: { id: 1234, type: "private" }, + sticker: { + file_id: "video_sticker_id", + file_unique_id: "video_unique", + type: "regular", + width: 512, + height: 512, + is_animated: false, + is_video: true, // WEBM format + emoji: "🎬", + set_name: "VideoPack", + }, + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ file_path: "stickers/video.webm" }), + }); + + // Should not attempt to download video stickers + expect(fetchSpy).not.toHaveBeenCalled(); + expect(replySpy).not.toHaveBeenCalled(); + expect(runtimeError).not.toHaveBeenCalled(); + + fetchSpy.mockRestore(); + }, + STICKER_TEST_TIMEOUT_MS, + ); +}); + describe("telegram text fragments", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index c2489300c..f950417c7 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -21,7 +21,8 @@ import { loadWebMedia } from "../../web/media.js"; import { buildInlineKeyboard } from "../send.js"; import { resolveTelegramVoiceSend } from "../voice.js"; import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js"; -import type { TelegramContext } from "./types.js"; +import type { StickerMetadata, TelegramContext } from "./types.js"; +import { getCachedSticker } from "../sticker-cache.js"; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; @@ -261,8 +262,79 @@ export async function resolveMedia( maxBytes: number, token: string, proxyFetch?: typeof fetch, -): Promise<{ path: string; contentType?: string; placeholder: string } | null> { +): Promise<{ + path: string; + contentType?: string; + placeholder: string; + stickerMetadata?: StickerMetadata; +} | null> { const msg = ctx.message; + + // Handle stickers separately - only static stickers (WEBP) are supported + if (msg.sticker) { + const sticker = msg.sticker; + // Skip animated (TGS) and video (WEBM) stickers - only static WEBP supported + if (sticker.is_animated || sticker.is_video) { + logVerbose("telegram: skipping animated/video sticker (only static stickers supported)"); + return null; + } + if (!sticker.file_id) return null; + + try { + const file = await ctx.getFile(); + if (!file.file_path) { + logVerbose("telegram: getFile returned no file_path for sticker"); + return null; + } + const fetchImpl = proxyFetch ?? globalThis.fetch; + if (!fetchImpl) { + logVerbose("telegram: fetch not available for sticker download"); + return null; + } + const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`; + const fetched = await fetchRemoteMedia({ + url, + fetchImpl, + filePathHint: file.file_path, + }); + const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes); + + // Check sticker cache for existing description + const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null; + if (cached) { + logVerbose(`telegram: sticker cache hit for ${sticker.file_unique_id}`); + return { + path: saved.path, + contentType: saved.contentType, + placeholder: "", + stickerMetadata: { + emoji: cached.emoji, + setName: cached.setName, + fileId: cached.fileId, + fileUniqueId: sticker.file_unique_id, + cachedDescription: cached.description, + }, + }; + } + + // Cache miss - return metadata for vision processing + return { + path: saved.path, + contentType: saved.contentType, + placeholder: "", + stickerMetadata: { + emoji: sticker.emoji ?? undefined, + setName: sticker.set_name ?? undefined, + fileId: sticker.file_id, + fileUniqueId: sticker.file_unique_id, + }, + }; + } catch (err) { + logVerbose(`telegram: failed to process sticker: ${err}`); + return null; + } + } + const m = msg.photo?.[msg.photo.length - 1] ?? msg.video ?? msg.document ?? msg.audio ?? msg.voice; if (!m?.file_id) return null; diff --git a/src/telegram/bot/types.ts b/src/telegram/bot/types.ts index 1174503b4..3e106b885 100644 --- a/src/telegram/bot/types.ts +++ b/src/telegram/bot/types.ts @@ -67,3 +67,17 @@ export interface TelegramVenue { google_place_id?: string; google_place_type?: string; } + +/** Telegram sticker metadata for context enrichment. */ +export interface StickerMetadata { + /** Emoji associated with the sticker. */ + emoji?: string; + /** Name of the sticker set the sticker belongs to. */ + setName?: string; + /** Telegram file_id for sending the sticker back. */ + fileId?: string; + /** Stable file_unique_id for cache deduplication. */ + fileUniqueId?: string; + /** Cached description from previous vision processing (skip re-processing if present). */ + cachedDescription?: string; +} diff --git a/src/telegram/send.returns-undefined-empty-input.test.ts b/src/telegram/send.returns-undefined-empty-input.test.ts index d086fe2a3..b6b497789 100644 --- a/src/telegram/send.returns-undefined-empty-input.test.ts +++ b/src/telegram/send.returns-undefined-empty-input.test.ts @@ -4,6 +4,7 @@ const { botApi, botCtorSpy } = vi.hoisted(() => ({ botApi: { sendMessage: vi.fn(), setMessageReaction: vi.fn(), + sendSticker: vi.fn(), }, botCtorSpy: vi.fn(), })); @@ -43,7 +44,7 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -import { buildInlineKeyboard, sendMessageTelegram } from "./send.js"; +import { buildInlineKeyboard, sendMessageTelegram, sendStickerTelegram } from "./send.js"; describe("buildInlineKeyboard", () => { it("returns undefined for empty input", () => { @@ -566,3 +567,183 @@ describe("sendMessageTelegram", () => { }); }); }); + +describe("sendStickerTelegram", () => { + beforeEach(() => { + loadConfig.mockReturnValue({}); + botApi.sendSticker.mockReset(); + botCtorSpy.mockReset(); + }); + + it("sends a sticker by file_id", async () => { + const chatId = "123"; + const fileId = "CAACAgIAAxkBAAI...sticker_file_id"; + const sendSticker = vi.fn().mockResolvedValue({ + message_id: 100, + chat: { id: chatId }, + }); + const api = { sendSticker } as unknown as { + sendSticker: typeof sendSticker; + }; + + const res = await sendStickerTelegram(chatId, fileId, { + token: "tok", + api, + }); + + expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, undefined); + expect(res.messageId).toBe("100"); + expect(res.chatId).toBe(chatId); + }); + + it("throws error when fileId is empty", async () => { + await expect(sendStickerTelegram("123", "", { token: "tok" })).rejects.toThrow( + /file_id is required/i, + ); + }); + + it("throws error when fileId is whitespace only", async () => { + await expect(sendStickerTelegram("123", " ", { token: "tok" })).rejects.toThrow( + /file_id is required/i, + ); + }); + + it("includes message_thread_id for forum topic messages", async () => { + const chatId = "-1001234567890"; + const fileId = "CAACAgIAAxkBAAI...sticker_file_id"; + const sendSticker = vi.fn().mockResolvedValue({ + message_id: 101, + chat: { id: chatId }, + }); + const api = { sendSticker } as unknown as { + sendSticker: typeof sendSticker; + }; + + await sendStickerTelegram(chatId, fileId, { + token: "tok", + api, + messageThreadId: 271, + }); + + expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, { + message_thread_id: 271, + }); + }); + + it("includes reply_to_message_id for threaded replies", async () => { + const chatId = "123"; + const fileId = "CAACAgIAAxkBAAI...sticker_file_id"; + const sendSticker = vi.fn().mockResolvedValue({ + message_id: 102, + chat: { id: chatId }, + }); + const api = { sendSticker } as unknown as { + sendSticker: typeof sendSticker; + }; + + await sendStickerTelegram(chatId, fileId, { + token: "tok", + api, + replyToMessageId: 500, + }); + + expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, { + reply_to_message_id: 500, + }); + }); + + it("includes both thread and reply params for forum topic replies", async () => { + const chatId = "-1001234567890"; + const fileId = "CAACAgIAAxkBAAI...sticker_file_id"; + const sendSticker = vi.fn().mockResolvedValue({ + message_id: 103, + chat: { id: chatId }, + }); + const api = { sendSticker } as unknown as { + sendSticker: typeof sendSticker; + }; + + await sendStickerTelegram(chatId, fileId, { + token: "tok", + api, + messageThreadId: 271, + replyToMessageId: 500, + }); + + expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, { + message_thread_id: 271, + reply_to_message_id: 500, + }); + }); + + it("normalizes chat ids with internal prefixes", async () => { + const sendSticker = vi.fn().mockResolvedValue({ + message_id: 104, + chat: { id: "123" }, + }); + const api = { sendSticker } as unknown as { + sendSticker: typeof sendSticker; + }; + + await sendStickerTelegram("telegram:123", "fileId123", { + token: "tok", + api, + }); + + expect(sendSticker).toHaveBeenCalledWith("123", "fileId123", undefined); + }); + + it("parses message_thread_id from recipient string (telegram:group:...:topic:...)", async () => { + const chatId = "-1001234567890"; + const sendSticker = vi.fn().mockResolvedValue({ + message_id: 105, + chat: { id: chatId }, + }); + const api = { sendSticker } as unknown as { + sendSticker: typeof sendSticker; + }; + + await sendStickerTelegram(`telegram:group:${chatId}:topic:271`, "fileId123", { + token: "tok", + api, + }); + + expect(sendSticker).toHaveBeenCalledWith(chatId, "fileId123", { + message_thread_id: 271, + }); + }); + + it("wraps chat-not-found with actionable context", async () => { + const chatId = "123"; + const err = new Error("400: Bad Request: chat not found"); + const sendSticker = vi.fn().mockRejectedValue(err); + const api = { sendSticker } as unknown as { + sendSticker: typeof sendSticker; + }; + + await expect(sendStickerTelegram(chatId, "fileId123", { token: "tok", api })).rejects.toThrow( + /chat not found/i, + ); + await expect(sendStickerTelegram(chatId, "fileId123", { token: "tok", api })).rejects.toThrow( + /chat_id=123/, + ); + }); + + it("trims whitespace from fileId", async () => { + const chatId = "123"; + const sendSticker = vi.fn().mockResolvedValue({ + message_id: 106, + chat: { id: chatId }, + }); + const api = { sendSticker } as unknown as { + sendSticker: typeof sendSticker; + }; + + await sendStickerTelegram(chatId, " fileId123 ", { + token: "tok", + api, + }); + + expect(sendSticker).toHaveBeenCalledWith(chatId, "fileId123", undefined); + }); +}); diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 92cd3ddc1..7dd79dd1f 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -619,3 +619,96 @@ function inferFilename(kind: ReturnType) { return "file.bin"; } } + +type TelegramStickerOpts = { + token?: string; + accountId?: string; + verbose?: boolean; + api?: Bot["api"]; + retry?: RetryConfig; + /** Message ID to reply to (for threading) */ + replyToMessageId?: number; + /** Forum topic thread ID (for forum supergroups) */ + messageThreadId?: number; +}; + +/** + * Send a sticker to a Telegram chat by file_id. + * @param to - Chat ID or username (e.g., "123456789" or "@username") + * @param fileId - Telegram file_id of the sticker to send + * @param opts - Optional configuration + */ +export async function sendStickerTelegram( + to: string, + fileId: string, + opts: TelegramStickerOpts = {}, +): Promise { + if (!fileId?.trim()) { + throw new Error("Telegram sticker file_id is required"); + } + + const cfg = loadConfig(); + const account = resolveTelegramAccount({ + cfg, + accountId: opts.accountId, + }); + const token = resolveToken(opts.token, account); + const target = parseTelegramTarget(to); + const chatId = normalizeChatId(target.chatId); + const client = resolveTelegramClientOptions(account); + const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; + + const messageThreadId = + opts.messageThreadId != null ? opts.messageThreadId : target.messageThreadId; + const threadIdParams = buildTelegramThreadParams(messageThreadId); + const threadParams: Record = threadIdParams ? { ...threadIdParams } : {}; + if (opts.replyToMessageId != null) { + threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId); + } + const hasThreadParams = Object.keys(threadParams).length > 0; + + const request = createTelegramRetryRunner({ + retry: opts.retry, + configRetry: account.config.retry, + verbose: opts.verbose, + }); + const logHttpError = createTelegramHttpLogger(cfg); + const requestWithDiag = (fn: () => Promise, label?: string) => + request(fn, label).catch((err) => { + logHttpError(label ?? "request", err); + throw err; + }); + + const wrapChatNotFound = (err: unknown) => { + if (!/400: Bad Request: chat not found/i.test(formatErrorMessage(err))) return err; + return new Error( + [ + `Telegram send failed: chat not found (chat_id=${chatId}).`, + "Likely: bot not started in DM, bot removed from group/channel, group migrated (new -100… id), or wrong bot token.", + `Input was: ${JSON.stringify(to)}.`, + ].join(" "), + ); + }; + + const stickerParams = hasThreadParams ? threadParams : undefined; + + const result = await requestWithDiag( + () => api.sendSticker(chatId, fileId.trim(), stickerParams), + "sticker", + ).catch((err) => { + throw wrapChatNotFound(err); + }); + + const messageId = String(result?.message_id ?? "unknown"); + const resolvedChatId = String(result?.chat?.id ?? chatId); + if (result?.message_id) { + recordSentMessage(chatId, result.message_id); + } + recordChannelActivity({ + channel: "telegram", + accountId: account.accountId, + direction: "outbound", + }); + + return { messageId, chatId: resolvedChatId }; +} diff --git a/src/telegram/sticker-cache.test.ts b/src/telegram/sticker-cache.test.ts new file mode 100644 index 000000000..7fa3b6af2 --- /dev/null +++ b/src/telegram/sticker-cache.test.ts @@ -0,0 +1,257 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + cacheSticker, + getAllCachedStickers, + getCachedSticker, + getCacheStats, + searchStickers, +} from "./sticker-cache.js"; + +// Mock the state directory to use a temp location +vi.mock("../config/paths.js", () => ({ + STATE_DIR_CLAWDBOT: "/tmp/clawdbot-test-sticker-cache", +})); + +const TEST_CACHE_DIR = "/tmp/clawdbot-test-sticker-cache/telegram"; +const TEST_CACHE_FILE = path.join(TEST_CACHE_DIR, "sticker-cache.json"); + +describe("sticker-cache", () => { + beforeEach(() => { + // Clean up before each test + if (fs.existsSync(TEST_CACHE_FILE)) { + fs.unlinkSync(TEST_CACHE_FILE); + } + }); + + afterEach(() => { + // Clean up after each test + if (fs.existsSync(TEST_CACHE_FILE)) { + fs.unlinkSync(TEST_CACHE_FILE); + } + }); + + describe("getCachedSticker", () => { + it("returns null for unknown ID", () => { + const result = getCachedSticker("unknown-id"); + expect(result).toBeNull(); + }); + + it("returns cached sticker after cacheSticker", () => { + const sticker = { + fileId: "file123", + fileUniqueId: "unique123", + emoji: "🎉", + setName: "TestPack", + description: "A party popper emoji sticker", + cachedAt: "2026-01-26T12:00:00.000Z", + }; + + cacheSticker(sticker); + const result = getCachedSticker("unique123"); + + expect(result).toEqual(sticker); + }); + + it("returns null after cache is cleared", () => { + const sticker = { + fileId: "file123", + fileUniqueId: "unique123", + description: "test", + cachedAt: "2026-01-26T12:00:00.000Z", + }; + + cacheSticker(sticker); + expect(getCachedSticker("unique123")).not.toBeNull(); + + // Manually clear the cache file + fs.unlinkSync(TEST_CACHE_FILE); + + expect(getCachedSticker("unique123")).toBeNull(); + }); + }); + + describe("cacheSticker", () => { + it("adds entry to cache", () => { + const sticker = { + fileId: "file456", + fileUniqueId: "unique456", + description: "A cute fox waving", + cachedAt: "2026-01-26T12:00:00.000Z", + }; + + cacheSticker(sticker); + + const all = getAllCachedStickers(); + expect(all).toHaveLength(1); + expect(all[0]).toEqual(sticker); + }); + + it("updates existing entry", () => { + const original = { + fileId: "file789", + fileUniqueId: "unique789", + description: "Original description", + cachedAt: "2026-01-26T12:00:00.000Z", + }; + const updated = { + fileId: "file789-new", + fileUniqueId: "unique789", + description: "Updated description", + cachedAt: "2026-01-26T13:00:00.000Z", + }; + + cacheSticker(original); + cacheSticker(updated); + + const result = getCachedSticker("unique789"); + expect(result?.description).toBe("Updated description"); + expect(result?.fileId).toBe("file789-new"); + }); + }); + + describe("searchStickers", () => { + beforeEach(() => { + // Seed cache with test stickers + cacheSticker({ + fileId: "fox1", + fileUniqueId: "fox-unique-1", + emoji: "🦊", + setName: "CuteFoxes", + description: "A cute orange fox waving hello", + cachedAt: "2026-01-26T10:00:00.000Z", + }); + cacheSticker({ + fileId: "fox2", + fileUniqueId: "fox-unique-2", + emoji: "🦊", + setName: "CuteFoxes", + description: "A fox sleeping peacefully", + cachedAt: "2026-01-26T11:00:00.000Z", + }); + cacheSticker({ + fileId: "cat1", + fileUniqueId: "cat-unique-1", + emoji: "🐱", + setName: "FunnyCats", + description: "A cat sitting on a keyboard", + cachedAt: "2026-01-26T12:00:00.000Z", + }); + cacheSticker({ + fileId: "dog1", + fileUniqueId: "dog-unique-1", + emoji: "🐶", + setName: "GoodBoys", + description: "A golden retriever playing fetch", + cachedAt: "2026-01-26T13:00:00.000Z", + }); + }); + + it("finds stickers by description substring", () => { + const results = searchStickers("fox"); + expect(results).toHaveLength(2); + expect(results.every((s) => s.description.toLowerCase().includes("fox"))).toBe(true); + }); + + it("finds stickers by emoji", () => { + const results = searchStickers("🦊"); + expect(results).toHaveLength(2); + expect(results.every((s) => s.emoji === "🦊")).toBe(true); + }); + + it("finds stickers by set name", () => { + const results = searchStickers("CuteFoxes"); + expect(results).toHaveLength(2); + expect(results.every((s) => s.setName === "CuteFoxes")).toBe(true); + }); + + it("respects limit parameter", () => { + const results = searchStickers("fox", 1); + expect(results).toHaveLength(1); + }); + + it("ranks exact matches higher", () => { + // "waving" appears in "fox waving hello" - should be ranked first + const results = searchStickers("waving"); + expect(results).toHaveLength(1); + expect(results[0]?.fileUniqueId).toBe("fox-unique-1"); + }); + + it("returns empty array for no matches", () => { + const results = searchStickers("elephant"); + expect(results).toHaveLength(0); + }); + + it("is case insensitive", () => { + const results = searchStickers("FOX"); + expect(results).toHaveLength(2); + }); + + it("matches multiple words", () => { + const results = searchStickers("cat keyboard"); + expect(results).toHaveLength(1); + expect(results[0]?.fileUniqueId).toBe("cat-unique-1"); + }); + }); + + describe("getAllCachedStickers", () => { + it("returns empty array when cache is empty", () => { + const result = getAllCachedStickers(); + expect(result).toEqual([]); + }); + + it("returns all cached stickers", () => { + cacheSticker({ + fileId: "a", + fileUniqueId: "a-unique", + description: "Sticker A", + cachedAt: "2026-01-26T10:00:00.000Z", + }); + cacheSticker({ + fileId: "b", + fileUniqueId: "b-unique", + description: "Sticker B", + cachedAt: "2026-01-26T11:00:00.000Z", + }); + + const result = getAllCachedStickers(); + expect(result).toHaveLength(2); + }); + }); + + describe("getCacheStats", () => { + it("returns count 0 when cache is empty", () => { + const stats = getCacheStats(); + expect(stats.count).toBe(0); + expect(stats.oldestAt).toBeUndefined(); + expect(stats.newestAt).toBeUndefined(); + }); + + it("returns correct stats with cached stickers", () => { + cacheSticker({ + fileId: "old", + fileUniqueId: "old-unique", + description: "Old sticker", + cachedAt: "2026-01-20T10:00:00.000Z", + }); + cacheSticker({ + fileId: "new", + fileUniqueId: "new-unique", + description: "New sticker", + cachedAt: "2026-01-26T10:00:00.000Z", + }); + cacheSticker({ + fileId: "mid", + fileUniqueId: "mid-unique", + description: "Middle sticker", + cachedAt: "2026-01-23T10:00:00.000Z", + }); + + const stats = getCacheStats(); + expect(stats.count).toBe(3); + expect(stats.oldestAt).toBe("2026-01-20T10:00:00.000Z"); + expect(stats.newestAt).toBe("2026-01-26T10:00:00.000Z"); + }); + }); +}); diff --git a/src/telegram/sticker-cache.ts b/src/telegram/sticker-cache.ts new file mode 100644 index 000000000..2c55563b7 --- /dev/null +++ b/src/telegram/sticker-cache.ts @@ -0,0 +1,201 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ClawdbotConfig } from "../config/config.js"; +import { STATE_DIR_CLAWDBOT } from "../config/paths.js"; +import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; +import { logVerbose } from "../globals.js"; +import { resolveApiKeyForProvider } from "../agents/model-auth.js"; + +const CACHE_FILE = path.join(STATE_DIR_CLAWDBOT, "telegram", "sticker-cache.json"); +const CACHE_VERSION = 1; + +export interface CachedSticker { + fileId: string; + fileUniqueId: string; + emoji?: string; + setName?: string; + description: string; + cachedAt: string; + receivedFrom?: string; +} + +interface StickerCache { + version: number; + stickers: Record; +} + +function loadCache(): StickerCache { + const data = loadJsonFile(CACHE_FILE); + if (!data || typeof data !== "object") { + return { version: CACHE_VERSION, stickers: {} }; + } + const cache = data as StickerCache; + if (cache.version !== CACHE_VERSION) { + // Future: handle migration if needed + return { version: CACHE_VERSION, stickers: {} }; + } + return cache; +} + +function saveCache(cache: StickerCache): void { + saveJsonFile(CACHE_FILE, cache); +} + +/** + * Get a cached sticker by its unique ID. + */ +export function getCachedSticker(fileUniqueId: string): CachedSticker | null { + const cache = loadCache(); + return cache.stickers[fileUniqueId] ?? null; +} + +/** + * Add or update a sticker in the cache. + */ +export function cacheSticker(sticker: CachedSticker): void { + const cache = loadCache(); + cache.stickers[sticker.fileUniqueId] = sticker; + saveCache(cache); +} + +/** + * Search cached stickers by text query (fuzzy match on description + emoji + setName). + */ +export function searchStickers(query: string, limit = 10): CachedSticker[] { + const cache = loadCache(); + const queryLower = query.toLowerCase(); + const results: Array<{ sticker: CachedSticker; score: number }> = []; + + for (const sticker of Object.values(cache.stickers)) { + let score = 0; + const descLower = sticker.description.toLowerCase(); + + // Exact substring match in description + if (descLower.includes(queryLower)) { + score += 10; + } + + // Word-level matching + const queryWords = queryLower.split(/\s+/).filter(Boolean); + const descWords = descLower.split(/\s+/); + for (const qWord of queryWords) { + if (descWords.some((dWord) => dWord.includes(qWord))) { + score += 5; + } + } + + // Emoji match + if (sticker.emoji && query.includes(sticker.emoji)) { + score += 8; + } + + // Set name match + if (sticker.setName?.toLowerCase().includes(queryLower)) { + score += 3; + } + + if (score > 0) { + results.push({ sticker, score }); + } + } + + return results + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map((r) => r.sticker); +} + +/** + * Get all cached stickers (for debugging/listing). + */ +export function getAllCachedStickers(): CachedSticker[] { + const cache = loadCache(); + return Object.values(cache.stickers); +} + +/** + * Get cache statistics. + */ +export function getCacheStats(): { count: number; oldestAt?: string; newestAt?: string } { + const cache = loadCache(); + const stickers = Object.values(cache.stickers); + if (stickers.length === 0) { + return { count: 0 }; + } + const sorted = [...stickers].sort( + (a, b) => new Date(a.cachedAt).getTime() - new Date(b.cachedAt).getTime(), + ); + return { + count: stickers.length, + oldestAt: sorted[0]?.cachedAt, + newestAt: sorted[sorted.length - 1]?.cachedAt, + }; +} + +const STICKER_DESCRIPTION_PROMPT = + "Describe this sticker image in 1-2 sentences. Focus on what the sticker depicts (character, object, action, emotion). Be concise and objective."; + +const VISION_PROVIDERS = ["anthropic", "openai", "google", "minimax"] as const; +const DEFAULT_VISION_MODELS: Record = { + anthropic: "claude-sonnet-4-20250514", + openai: "gpt-4o-mini", + google: "gemini-2.0-flash", + minimax: "MiniMax-VL-01", +}; + +export interface DescribeStickerParams { + imagePath: string; + cfg: ClawdbotConfig; + agentDir?: string; +} + +/** + * Describe a sticker image using vision API. + * Auto-detects an available vision provider based on configured API keys. + * Returns null if no vision provider is available. + */ +export async function describeStickerImage(params: DescribeStickerParams): Promise { + const { imagePath, cfg, agentDir } = params; + + // Find a vision provider with available API key + let provider: string | null = null; + for (const p of VISION_PROVIDERS) { + try { + await resolveApiKeyForProvider({ provider: p, cfg, agentDir }); + provider = p; + break; + } catch { + // No key for this provider, try next + } + } + + if (!provider) { + logVerbose("telegram: no vision provider available for sticker description"); + return null; + } + + const model = DEFAULT_VISION_MODELS[provider]; + logVerbose(`telegram: describing sticker with ${provider}/${model}`); + + try { + const buffer = await fs.readFile(imagePath); + // Dynamic import to avoid circular dependency + const { describeImageWithModel } = await import("../media-understanding/providers/image.js"); + const result = await describeImageWithModel({ + buffer, + fileName: "sticker.webp", + mime: "image/webp", + prompt: STICKER_DESCRIPTION_PROMPT, + cfg, + agentDir: agentDir ?? "", + provider, + model, + maxTokens: 150, + timeoutMs: 30000, + }); + return result.text; + } catch (err) { + logVerbose(`telegram: failed to describe sticker: ${err}`); + return null; + } +}