Files
Moltbot/src/telegram/sticker-cache.ts
2026-01-27 13:49:42 +05:30

262 lines
7.5 KiB
TypeScript

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 type { ModelCatalogEntry } from "../agents/model-catalog.js";
import {
findModelInCatalog,
loadModelCatalog,
modelSupportsVision,
} from "../agents/model-catalog.js";
import { resolveApiKeyForProvider } from "../agents/model-auth.js";
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
import { resolveAutoImageModel } from "../media-understanding/runner.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 = ["openai", "anthropic", "google", "minimax"] as const;
export interface DescribeStickerParams {
imagePath: string;
cfg: ClawdbotConfig;
agentDir?: string;
agentId?: 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, agentId } = params;
const defaultModel = resolveDefaultModelForAgent({ cfg, agentId });
let activeModel = undefined as { provider: string; model: string } | undefined;
let catalog: ModelCatalogEntry[] = [];
try {
catalog = await loadModelCatalog({ config: cfg });
const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model);
const supportsVision = entry?.input ? modelSupportsVision(entry) : Boolean(entry);
if (supportsVision) {
activeModel = { provider: defaultModel.provider, model: defaultModel.model };
}
} catch {
// Ignore catalog failures; fall back to auto selection.
}
const hasProviderKey = async (provider: string) => {
try {
await resolveApiKeyForProvider({ provider, cfg, agentDir });
return true;
} catch {
return false;
}
};
const selectCatalogModel = (provider: string) => {
const entries = catalog.filter(
(entry) =>
entry.provider.toLowerCase() === provider.toLowerCase() &&
(entry.input ? modelSupportsVision(entry) : true),
);
if (entries.length === 0) return undefined;
const defaultId =
provider === "openai"
? "gpt-5-mini"
: provider === "anthropic"
? "claude-opus-4-5"
: provider === "google"
? "gemini-3-flash-preview"
: "MiniMax-VL-01";
const preferred = entries.find((entry) => entry.id === defaultId);
return preferred ?? entries[0];
};
let resolved = null as { provider: string; model?: string } | null;
if (
activeModel &&
VISION_PROVIDERS.includes(activeModel.provider as (typeof VISION_PROVIDERS)[number]) &&
(await hasProviderKey(activeModel.provider))
) {
resolved = activeModel;
}
if (!resolved) {
for (const provider of VISION_PROVIDERS) {
if (!(await hasProviderKey(provider))) continue;
const entry = selectCatalogModel(provider);
if (entry) {
resolved = { provider, model: entry.id };
break;
}
}
}
if (!resolved) {
resolved = await resolveAutoImageModel({
cfg,
agentDir,
activeModel,
});
}
if (!resolved?.model) {
logVerbose("telegram: no vision provider available for sticker description");
return null;
}
const { provider, model } = resolved;
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: ${String(err)}`);
return null;
}
}