mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 15:48:28 +00:00
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:
201
src/telegram/sticker-cache.ts
Normal file
201
src/telegram/sticker-cache.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user