262 lines
7.5 KiB
TypeScript
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;
|
|
}
|
|
}
|