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

@@ -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 = "<media:video>";
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
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 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,