feat(telegram): add sticker support with vision caching

Add support for receiving and sending Telegram stickers:

Inbound:
- Receive static WEBP stickers (skip animated/video)
- Process stickers through dedicated vision call for descriptions
- Cache vision descriptions to avoid repeated API calls
- Graceful error handling for fetch failures

Outbound:
- Add sticker action to send stickers by fileId
- Add sticker-search action to find cached stickers by query
- Accept stickerId from shared schema, convert to fileId

Cache:
- Store sticker metadata (fileId, emoji, setName, description)
- Fuzzy search by description, emoji, and set name
- Persist to ~/.clawdbot/telegram/sticker-cache.json

Config:
- Single `channels.telegram.actions.sticker` option enables both
  send and search actions

🤖 AI-assisted: Built with Claude Code (claude-opus-4-5)
Testing: Fully tested - unit tests pass, live tested on dev gateway
The contributor understands and has reviewed all code changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Josh Long
2026-01-26 22:07:43 +00:00
committed by Ayaan Zaidi
parent 9daa846457
commit 506bed5aed
18 changed files with 1365 additions and 14 deletions

View File

@@ -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 `<media:sticker>` 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) ## Streaming (drafts)
Telegram can stream **draft bubbles** while the agent is generating a response. Telegram can stream **draft bubbles** while the agent is generating a response.
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the 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.reactions`: gate Telegram tool reactions.
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends. - `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes. - `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.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). - `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set).

View File

@@ -6,7 +6,9 @@ import {
editMessageTelegram, editMessageTelegram,
reactMessageTelegram, reactMessageTelegram,
sendMessageTelegram, sendMessageTelegram,
sendStickerTelegram,
} from "../../telegram/send.js"; } from "../../telegram/send.js";
import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js";
import { resolveTelegramToken } from "../../telegram/token.js"; import { resolveTelegramToken } from "../../telegram/token.js";
import { import {
resolveTelegramInlineButtonsScope, 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}`); throw new Error(`Unsupported Telegram action: ${action}`);
} }

View File

@@ -1,4 +1,5 @@
import type { ChannelId } from "../channels/plugins/types.js"; 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 { InternalMessageChannel } from "../utils/message-channel.js";
import type { CommandArgs } from "./commands-registry.types.js"; import type { CommandArgs } from "./commands-registry.types.js";
import type { import type {
@@ -64,6 +65,8 @@ export type MsgContext = {
MediaPaths?: string[]; MediaPaths?: string[];
MediaUrls?: string[]; MediaUrls?: string[];
MediaTypes?: string[]; MediaTypes?: string[];
/** Telegram sticker metadata (emoji, set name, file IDs, cached description). */
Sticker?: StickerMetadata;
OutputDir?: string; OutputDir?: string;
OutputBase?: string; OutputBase?: string;
/** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */ /** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */

View File

@@ -1,6 +1,7 @@
import { import {
createActionGate, createActionGate,
readNumberParam, readNumberParam,
readStringArrayParam,
readStringOrNumberParam, readStringOrNumberParam,
readStringParam, readStringParam,
} from "../../../agents/tools/common.js"; } from "../../../agents/tools/common.js";
@@ -45,6 +46,10 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
if (gate("reactions")) actions.add("react"); if (gate("reactions")) actions.add("react");
if (gate("deleteMessage")) actions.add("delete"); if (gate("deleteMessage")) actions.add("delete");
if (gate("editMessage")) actions.add("edit"); if (gate("editMessage")) actions.add("edit");
if (gate("sticker")) {
actions.add("sticker");
actions.add("sticker-search");
}
return Array.from(actions); return Array.from(actions);
}, },
supportsButtons: ({ cfg }) => { 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}.`); throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
}, },
}; };

View File

@@ -25,6 +25,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
"thread-reply", "thread-reply",
"search", "search",
"sticker", "sticker",
"sticker-search",
"member-info", "member-info",
"role-info", "role-info",
"emoji-list", "emoji-list",

View File

@@ -16,6 +16,8 @@ export type TelegramActionConfig = {
sendMessage?: boolean; sendMessage?: boolean;
deleteMessage?: boolean; deleteMessage?: boolean;
editMessage?: boolean; editMessage?: boolean;
/** Enable sticker actions (send and search). */
sticker?: boolean;
}; };
export type TelegramNetworkConfig = { export type TelegramNetworkConfig = {

View File

@@ -128,6 +128,7 @@ export const TelegramAccountSchemaBase = z
reactions: z.boolean().optional(), reactions: z.boolean().optional(),
sendMessage: z.boolean().optional(), sendMessage: z.boolean().optional(),
deleteMessage: z.boolean().optional(), deleteMessage: z.boolean().optional(),
sticker: z.boolean().optional(),
}) })
.strict() .strict()
.optional(), .optional(),

View File

@@ -30,6 +30,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
"thread-reply": "to", "thread-reply": "to",
search: "none", search: "none",
sticker: "to", sticker: "to",
"sticker-search": "none",
"member-info": "none", "member-info": "none",
"role-info": "none", "role-info": "none",
"emoji-list": "none", "emoji-list": "none",

View File

@@ -112,11 +112,19 @@ export const registerTelegramHandlers = ({
const captionMsg = entry.messages.find((m) => m.msg.caption || m.msg.text); const captionMsg = entry.messages.find((m) => m.msg.caption || m.msg.text);
const primaryEntry = captionMsg ?? entry.messages[0]; 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) { for (const { ctx } of entry.messages) {
const media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch); const media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch);
if (media) { 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; 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 senderId = msg.from?.id ? String(msg.from.id) : "";
const conversationKey = const conversationKey =
resolvedThreadId != null ? `${chatId}:topic:${resolvedThreadId}` : String(chatId); resolvedThreadId != null ? `${chatId}:topic:${resolvedThreadId}` : String(chatId);

View File

@@ -49,7 +49,17 @@ import {
import { upsertTelegramPairingRequest } from "./pairing-store.js"; import { upsertTelegramPairingRequest } from "./pairing-store.js";
import type { TelegramContext } from "./bot/types.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 = { type TelegramMessageContextOptions = {
forceWasMentioned?: boolean; forceWasMentioned?: boolean;
@@ -302,6 +312,18 @@ export const buildTelegramMessageContext = async ({
else if (msg.video) placeholder = "<media:video>"; else if (msg.video) placeholder = "<media:video>";
else if (msg.audio || msg.voice) placeholder = "<media:audio>"; else if (msg.audio || msg.voice) placeholder = "<media:audio>";
else if (msg.document) placeholder = "<media:document>"; else if (msg.document) placeholder = "<media:document>";
else if (msg.sticker) placeholder = "<media:sticker>";
// 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 locationData = extractTelegramLocation(msg);
const locationText = locationData ? formatLocationText(locationData) : undefined; const locationText = locationData ? formatLocationText(locationData) : undefined;
@@ -525,15 +547,26 @@ export const buildTelegramMessageContext = async ({
ForwardedDate: forwardOrigin?.date ? forwardOrigin.date * 1000 : undefined, ForwardedDate: forwardOrigin?.date ? forwardOrigin.date * 1000 : undefined,
Timestamp: msg.date ? msg.date * 1000 : undefined, Timestamp: msg.date ? msg.date * 1000 : undefined,
WasMentioned: isGroup ? effectiveWasMentioned : undefined, WasMentioned: isGroup ? effectiveWasMentioned : undefined,
MediaPath: allMedia[0]?.path, // Filter out cached stickers from media - their description is already in the message body
MediaType: allMedia[0]?.contentType, MediaPath: stickerCacheHit ? undefined : allMedia[0]?.path,
MediaUrl: allMedia[0]?.path, MediaType: stickerCacheHit ? undefined : allMedia[0]?.contentType,
MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined, MediaUrl: stickerCacheHit ? undefined : allMedia[0]?.path,
MediaUrls: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined, MediaPaths: stickerCacheHit
MediaTypes: ? undefined
allMedia.length > 0 : 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[]) ? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
: undefined, : undefined,
Sticker: allMedia[0]?.stickerMetadata,
...(locationData ? toLocationContext(locationData) : undefined), ...(locationData ? toLocationContext(locationData) : undefined),
CommandAuthorized: commandAuthorized, CommandAuthorized: commandAuthorized,
MessageThreadId: resolvedThreadId, MessageThreadId: resolvedThreadId,

View File

@@ -12,6 +12,8 @@ import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { deliverReplies } from "./bot/delivery.js"; import { deliverReplies } from "./bot/delivery.js";
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js"; import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
import { createTelegramDraftStream } from "./draft-stream.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 ({ export const dispatchTelegramMessage = async ({
context, context,
@@ -128,6 +130,49 @@ export const dispatchTelegramMessage = async ({
}); });
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId); 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({ const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload, ctx: ctxPayload,
cfg, cfg,
@@ -139,6 +184,7 @@ export const dispatchTelegramMessage = async ({
await flushDraft(); await flushDraft();
draftStream?.stop(); draftStream?.stop();
} }
await deliverReplies({ await deliverReplies({
replies: [payload], replies: [payload],
chatId: String(chatId), chatId: String(chatId),

View File

@@ -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<typeof vi.fn>;
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<string, unknown>,
) => Promise<void>;
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("<media:sticker>");
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<typeof vi.fn>;
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<string, unknown>,
) => Promise<void>;
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<typeof vi.fn>;
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<string, unknown>,
) => Promise<void>;
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", () => { describe("telegram text fragments", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();

View File

@@ -21,7 +21,8 @@ import { loadWebMedia } from "../../web/media.js";
import { buildInlineKeyboard } from "../send.js"; import { buildInlineKeyboard } from "../send.js";
import { resolveTelegramVoiceSend } from "../voice.js"; import { resolveTelegramVoiceSend } from "../voice.js";
import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.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 PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
@@ -261,8 +262,79 @@ export async function resolveMedia(
maxBytes: number, maxBytes: number,
token: string, token: string,
proxyFetch?: typeof fetch, 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; 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: "<media:sticker>",
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: "<media:sticker>",
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 = const m =
msg.photo?.[msg.photo.length - 1] ?? msg.video ?? msg.document ?? msg.audio ?? msg.voice; msg.photo?.[msg.photo.length - 1] ?? msg.video ?? msg.document ?? msg.audio ?? msg.voice;
if (!m?.file_id) return null; if (!m?.file_id) return null;

View File

@@ -67,3 +67,17 @@ export interface TelegramVenue {
google_place_id?: string; google_place_id?: string;
google_place_type?: 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;
}

View File

@@ -4,6 +4,7 @@ const { botApi, botCtorSpy } = vi.hoisted(() => ({
botApi: { botApi: {
sendMessage: vi.fn(), sendMessage: vi.fn(),
setMessageReaction: vi.fn(), setMessageReaction: vi.fn(),
sendSticker: vi.fn(),
}, },
botCtorSpy: 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", () => { describe("buildInlineKeyboard", () => {
it("returns undefined for empty input", () => { 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);
});
});

View File

@@ -619,3 +619,96 @@ function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
return "file.bin"; 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<TelegramSendResult> {
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<string, number> = 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 = <T>(fn: () => Promise<T>, 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 };
}

View File

@@ -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");
});
});
});

View File

@@ -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<string, CachedSticker>;
}
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<string, string> = {
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<string | null> {
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;
}
}