"use node"; import { action } from "./_generated/server"; import { v } from "convex/values"; /** * GIF search action — backed by the Klipy GIF API (Tenor's * Google-shutdown replacement). Klipy embeds the customer id in the * URL path and returns results under `data.data[]` with sized * variants under `file.{hd|md|sm}.gif.url`. We normalize the response * into a flat `{results: [{id, title, url, previewUrl, width, height}]}` * shape so callers don't have to know which provider is upstream. * * Reads `KLIPY_API_KEY` from the Convex environment. Falls back to * the legacy `TENOR_API_KEY` env var so existing deployments keep * working as soon as the old key is replaced with a Klipy customer * id — no `convex env set` rename required. */ interface NormalizedGif { id: string; title: string; url: string; previewUrl: string; width?: number; height?: number; } interface GifSearchResponse { results: NormalizedGif[]; } /** * In-memory TTL cache for Klipy responses. Convex Node actions run on * warm container instances, so this survives between invocations on * the same worker until it's recycled. Best-effort only — a cold * start will re-fetch upstream. Search queries live for 5 minutes, * trending / categories for 30 minutes since they change slowly. */ interface CacheEntry { value: T; expiresAt: number; } const CACHE = new Map>(); const MAX_CACHE_SIZE = 200; const SEARCH_TTL_MS = 5 * 60 * 1000; const TRENDING_TTL_MS = 30 * 60 * 1000; const CATEGORIES_TTL_MS = 30 * 60 * 1000; function cacheGet(key: string): T | null { const entry = CACHE.get(key); if (!entry) return null; if (entry.expiresAt < Date.now()) { CACHE.delete(key); return null; } return entry.value as T; } function cacheSet(key: string, value: T, ttlMs: number): void { if (CACHE.size >= MAX_CACHE_SIZE) { // Drop the oldest insertion — Map iteration order is insertion order. const oldest = CACHE.keys().next().value; if (oldest !== undefined) CACHE.delete(oldest); } CACHE.set(key, { value, expiresAt: Date.now() + ttlMs }); } function normalizeGifItems(items: any[]): NormalizedGif[] { return items .map((item, idx): NormalizedGif => { const file = item?.file ?? {}; const md = file?.md?.gif ?? file?.sm?.gif ?? file?.hd?.gif ?? {}; const previewVariant = file?.sm?.gif ?? file?.md?.gif ?? file?.hd?.gif ?? {}; const fullUrl: string = md?.url ?? item?.url ?? ""; const previewUrl: string = previewVariant?.url ?? fullUrl; const width: number | undefined = typeof md?.width === "number" ? md.width : undefined; const height: number | undefined = typeof md?.height === "number" ? md.height : undefined; return { id: String(item?.slug ?? item?.id ?? `${idx}`), title: String(item?.title ?? ""), url: fullUrl, previewUrl, width, height, }; }) .filter((r) => !!r.url); } export const search = action({ args: { q: v.string(), limit: v.optional(v.number()), }, returns: v.any(), handler: async (_ctx, args): Promise => { const apiKey = process.env.KLIPY_API_KEY || process.env.TENOR_API_KEY; if (!apiKey) { console.warn("KLIPY_API_KEY missing"); return { results: [] }; } const limit = Math.min(Math.max(args.limit ?? 24, 1), 50); const query = args.q.trim().toLowerCase(); const cacheKey = `search:${query}:${limit}`; const cached = cacheGet(cacheKey); if (cached) return cached; // Klipy customer id goes in the path; per-page caps the result // set without a separate `limit` query param. const url = `https://api.klipy.com/api/v1/${encodeURIComponent(apiKey)}/gifs/search?q=${encodeURIComponent(args.q)}&per_page=${limit}&page=1&locale=en`; let response: Response; try { response = await fetch(url, { headers: { Accept: "application/json", "User-Agent": "Brycord/1.0 (+https://brycord.com) Klipy-Client", }, }); } catch (err) { console.error("Klipy fetch error:", err); return { results: [] }; } if (!response.ok) { console.error("Klipy API error:", response.status, response.statusText); return { results: [] }; } const json = (await response.json()) as any; const items: any[] = Array.isArray(json?.data?.data) ? json.data.data : []; const results = normalizeGifItems(items); const payload: GifSearchResponse = { results }; cacheSet(cacheKey, payload, SEARCH_TTL_MS); return payload; }, }); /** * Trending GIFs — used for the picker's home feed when no query is * typed. Returns the same normalized shape as `search` so callers * can use a single render path for both. */ export const trending = action({ args: { limit: v.optional(v.number()), }, returns: v.any(), handler: async (_ctx, args): Promise => { const apiKey = process.env.KLIPY_API_KEY || process.env.TENOR_API_KEY; if (!apiKey) return { results: [] }; const limit = Math.min(Math.max(args.limit ?? 24, 1), 50); const cacheKey = `trending:${limit}`; const cached = cacheGet(cacheKey); if (cached) return cached; const url = `https://api.klipy.com/api/v1/${encodeURIComponent(apiKey)}/gifs/trending?per_page=${limit}&page=1&locale=en`; let response: Response; try { response = await fetch(url, { headers: { Accept: "application/json", "User-Agent": "Brycord/1.0 (+https://brycord.com) Klipy-Client", }, }); } catch (err) { console.error("Klipy trending fetch error:", err); return { results: [] }; } if (!response.ok) { console.error( "Klipy trending API error:", response.status, response.statusText, ); return { results: [] }; } const json = (await response.json()) as any; const items: any[] = Array.isArray(json?.data?.data) ? json.data.data : []; const results = normalizeGifItems(items); const payload: GifSearchResponse = { results }; cacheSet(cacheKey, payload, TRENDING_TTL_MS); return payload; }, }); /** * Trending categories — Klipy exposes `/categories` returning a list * of slugs the picker can show as quick-search chips. Normalized * into `{categories: [{name, image, query}]}` so the consumer * doesn't depend on the upstream shape. */ interface NormalizedCategory { name: string; image: string; query: string; } export const categories = action({ args: {}, returns: v.any(), handler: async (): Promise<{ categories: NormalizedCategory[] }> => { const apiKey = process.env.KLIPY_API_KEY || process.env.TENOR_API_KEY; if (!apiKey) { return { categories: [] }; } const cacheKey = `categories`; const cached = cacheGet<{ categories: NormalizedCategory[] }>(cacheKey); if (cached) return cached; const url = `https://api.klipy.com/api/v1/${encodeURIComponent(apiKey)}/gifs/categories?locale=en`; let response: Response; try { response = await fetch(url, { headers: { Accept: "application/json", "User-Agent": "Brycord/1.0 (+https://brycord.com) Klipy-Client", }, }); } catch (err) { console.error("Klipy categories fetch error:", err); return { categories: [] }; } if (!response.ok) { console.error( "Klipy categories API error:", response.status, response.statusText, ); return { categories: [] }; } const json = (await response.json()) as any; const items: any[] = Array.isArray(json?.data?.data) ? json.data.data : Array.isArray(json?.data) ? json.data : []; const categories: NormalizedCategory[] = items .map((item) => ({ name: String(item?.name ?? item?.title ?? ""), image: String(item?.image ?? item?.preview ?? ""), query: String(item?.query ?? item?.search_term ?? item?.name ?? ""), })) .filter((c) => !!c.query); const payload = { categories }; cacheSet(cacheKey, payload, CATEGORIES_TTL_MS); return payload; }, });