feat(ai-moderation): replace base64 sticker cache with image URL storage and add uploadAndCacheSticker helper

This commit is contained in:
MythEclipse
2026-06-06 14:11:46 +07:00
parent 6a3d0eb17c
commit a3e1801b24
@@ -1,19 +1,20 @@
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { config } from "../../shared/config/config.js";
import { uploadToTele } from "../attachment-upload/teleUpload.js";
import { executeAll, executeGet } from "../../shared/database/drizzle.js"; import { executeAll, executeGet } from "../../shared/database/drizzle.js";
const logger = createChildLogger("sticker-cache"); const logger = createChildLogger("sticker-cache");
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const MAX_SIZE_BYTES = 100 * 1024 * 1024; // 100MB hardcoded const MAX_ENTRIES = 5000;
let ready = false; let ready = false;
let statsCache = { entryCount: 0, totalSizeBytes: 0 }; let statsCache = { entryCount: 0 };
export interface StickerCacheEntry { export interface StickerCacheEntry {
base64: string; imageUrl: string;
mimeType: string; mimeType: string;
fetchedAt: number; fetchedAt: number;
size: number;
} }
function sanitizeKey(name: string): string { function sanitizeKey(name: string): string {
@@ -32,9 +33,8 @@ export async function initStickerCache(): Promise<void> {
await executeAll(` await executeAll(`
CREATE TABLE IF NOT EXISTS "sticker_cache" ( CREATE TABLE IF NOT EXISTS "sticker_cache" (
"name" text PRIMARY KEY NOT NULL, "name" text PRIMARY KEY NOT NULL,
"base64" text NOT NULL, "image_url" text NOT NULL DEFAULT '',
"mime_type" text NOT NULL, "mime_type" text NOT NULL,
"size" integer NOT NULL,
"fetched_at" bigint NOT NULL "fetched_at" bigint NOT NULL
) )
`); `);
@@ -42,17 +42,17 @@ export async function initStickerCache(): Promise<void> {
`CREATE INDEX IF NOT EXISTS "idx_sticker_cache_fetched_at" ON "sticker_cache" USING btree ("fetched_at")`, `CREATE INDEX IF NOT EXISTS "idx_sticker_cache_fetched_at" ON "sticker_cache" USING btree ("fetched_at")`,
); );
// Prune expired entries
await executeAll("DELETE FROM sticker_cache WHERE fetched_at < $1", [ await executeAll("DELETE FROM sticker_cache WHERE fetched_at < $1", [
Date.now() - TTL_MS, Date.now() - TTL_MS,
]); ]);
const row = await executeGet( const row = await executeGet(
"SELECT count(*) as cnt, COALESCE(SUM(size), 0) as total FROM sticker_cache", "SELECT count(*) as cnt FROM sticker_cache",
[], [],
); );
if (row) { if (row) {
statsCache = { statsCache = {
entryCount: Number(row.cnt), entryCount: Number(row.cnt),
totalSizeBytes: Number(row.total),
}; };
} }
} catch (err) { } catch (err) {
@@ -62,7 +62,7 @@ export async function initStickerCache(): Promise<void> {
); );
} }
ready = true; ready = true;
logger.info(statsCache, "Sticker cache initialized (PostgreSQL)"); logger.info(statsCache, "Sticker cache initialized (PostgreSQL — URL-based)");
} }
/** /**
@@ -75,15 +75,14 @@ export async function getStickerFromCache(
const key = sanitizeKey(stickerName); const key = sanitizeKey(stickerName);
try { try {
const row = await executeGet( const row = await executeGet(
"SELECT base64, mime_type, size, fetched_at FROM sticker_cache WHERE name = $1 AND fetched_at > $2", "SELECT image_url, mime_type, fetched_at FROM sticker_cache WHERE name = $1 AND fetched_at > $2",
[key, Date.now() - TTL_MS], [key, Date.now() - TTL_MS],
); );
if (!row) return null; if (!row) return null;
return { return {
base64: row.base64, imageUrl: row.image_url,
mimeType: row.mime_type, mimeType: row.mime_type,
fetchedAt: Number(row.fetched_at), fetchedAt: Number(row.fetched_at),
size: Number(row.size),
}; };
} catch (err) { } catch (err) {
logger.error( logger.error(
@@ -95,68 +94,100 @@ export async function getStickerFromCache(
} }
/** /**
* Store a sticker image in the cache. Fires and forgets — never blocks. * Store a sticker image URL in the cache.
*
* @param stickerName - The sticker's display name (used as cache key).
* @param imageUrl - The uploaded image URL (tele/picser) to store.
* @param mimeType - MIME type of the image.
*/ */
export async function setStickerInCache( export async function setStickerInCache(
stickerName: string, stickerName: string,
base64: string, imageUrl: string,
mimeType: string, mimeType: string,
): Promise<void> { ): Promise<void> {
if (!ready) return; if (!ready) return;
const key = sanitizeKey(stickerName); const key = sanitizeKey(stickerName);
const size = Buffer.byteLength(base64, "utf-8");
const now = Date.now(); const now = Date.now();
try { try {
await evictIfNeeded(size); await evictIfNeeded();
await executeAll( await executeAll(
`INSERT INTO sticker_cache (name, base64, mime_type, size, fetched_at) `INSERT INTO sticker_cache (name, image_url, mime_type, fetched_at)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4)
ON CONFLICT (name) DO UPDATE SET ON CONFLICT (name) DO UPDATE SET
base64 = EXCLUDED.base64, image_url = EXCLUDED.image_url,
mime_type = EXCLUDED.mime_type, mime_type = EXCLUDED.mime_type,
size = EXCLUDED.size,
fetched_at = EXCLUDED.fetched_at`, fetched_at = EXCLUDED.fetched_at`,
[key, base64, mimeType, size, now], [key, imageUrl, mimeType, now],
); );
statsCache.entryCount++; statsCache.entryCount++;
statsCache.totalSizeBytes += size; logger.debug({ stickerName, imageUrl }, "Sticker URL cached");
logger.debug({ stickerName, size }, "Sticker cached");
} catch (err) { } catch (err) {
logger.warn( logger.warn(
{ stickerName, error: String(err) }, { stickerName, error: String(err) },
"Failed to write sticker to cache", "Failed to write sticker URL to cache",
); );
} }
} }
async function evictIfNeeded(newSize: number): Promise<void> { /**
if (statsCache.totalSizeBytes + newSize <= MAX_SIZE_BYTES) return; * Upload a raw sticker image buffer to the external upload service, then
* cache the resulting URL.
*
* Convenience wrapper for the common pattern:
* download → setStickerImage → use URL.
*/
export async function uploadAndCacheSticker(
stickerName: string,
buffer: Buffer,
mimeType: string,
): Promise<string | null> {
let uploadedUrl: string;
try {
uploadedUrl = await uploadToTele({
buffer,
filename: `sticker-${sanitizeKey(stickerName)}`,
contentType: mimeType,
uploadUrl: config.TELE_UPLOAD_URL,
timeoutMs: 30000,
retries: 2,
}).then((r) => r.url);
} catch (err) {
logger.warn(
{ stickerName, error: String(err) },
"Failed to upload sticker — not caching",
);
return null;
}
const targetToFree = statsCache.totalSizeBytes + newSize - MAX_SIZE_BYTES; // Fire-and-forget cache write (non-blocking)
setStickerInCache(stickerName, uploadedUrl, mimeType).catch(() => {});
return uploadedUrl;
}
async function evictIfNeeded(): Promise<void> {
if (statsCache.entryCount < MAX_ENTRIES) return;
const targetToFree = statsCache.entryCount - MAX_ENTRIES + 1; // evict oldest N
// Batch eviction: identify all rows to delete, then DELETE in one query.
const rowsToEvict = await executeAll( const rowsToEvict = await executeAll(
"SELECT name, size FROM sticker_cache ORDER BY fetched_at ASC", "SELECT name FROM sticker_cache ORDER BY fetched_at ASC LIMIT $1",
[targetToFree],
); );
if (!rowsToEvict || rowsToEvict.length === 0) return; if (!rowsToEvict || rowsToEvict.length === 0) return;
const namesToDelete: string[] = []; const namesToDelete = (rowsToEvict as Array<{ name: string }>).map(
let freed = 0; (r) => r.name,
for (const row of rowsToEvict as Array<{ name: string; size: number }>) { );
namesToDelete.push(row.name);
freed += Number(row.size);
if (freed >= targetToFree) break;
}
await executeAll(`DELETE FROM sticker_cache WHERE name = ANY($1)`, [ await executeAll(`DELETE FROM sticker_cache WHERE name = ANY($1)`, [
namesToDelete, namesToDelete,
]); ]);
statsCache.totalSizeBytes -= freed;
statsCache.entryCount -= namesToDelete.length; statsCache.entryCount -= namesToDelete.length;
logger.debug( logger.debug(
{ entriesRemoved: namesToDelete.length, freedBytes: freed }, { entriesRemoved: namesToDelete.length },
"Batch-evicted sticker cache entries", "Batch-evicted oldest sticker cache entries",
); );
} }
@@ -165,13 +196,12 @@ async function evictIfNeeded(newSize: number): Promise<void> {
*/ */
export function getStickerCacheStats(): { export function getStickerCacheStats(): {
entryCount: number; entryCount: number;
totalSizeBytes: number;
} { } {
return { ...statsCache }; return { ...statsCache };
} }
/** /**
* Check if cache has been initialized. * Check if cache has been initialised.
*/ */
export function isStickerCacheReady(): boolean { export function isStickerCacheReady(): boolean {
return ready; return ready;