import { clamp } from "../utils.ts"; import { normalizeWhitespaceText } from "../normalization/text.ts";
const GIPHY_SEARCH_API_URL = "https://api.giphy.com/v1/gifs/search"; const GIF_TIMEOUT_MS = 8_500; const GIF_USER_AGENT = "clanky/0.1 (+gif-search; https://github.com/Volpestyle/clanky)"; const MAX_GIF_QUERY_LEN = 120; const GIPHY_ALLOWED_RATINGS = new Set(["g", "pg", "pg-13", "r"]); type GifTrace = { guildId?: string | null; channelId?: string | null; userId?: string | null; source?: string | null; };
export class GifService { store; apiKey; rating;
constructor({ appConfig, store }) { this.store = store; this.apiKey = String(appConfig?.giphyApiKey || "").trim(); this.rating = normalizeGiphyRating(appConfig?.giphyRating); }
isConfigured() { return Boolean(this.apiKey); }
async pickGif({ query, trace = {} as GifTrace }) { if (!this.isConfigured()) { throw new Error("GIPHY GIF search is not configured. Set GIPHY_API_KEY."); }
const normalizedQuery = sanitizeExternalText(query, MAX_GIF_QUERY_LEN);
if (!normalizedQuery) {
return null;
}
try {
const matches = await this.searchGiphy({
query: normalizedQuery,
limit: 10
});
const selected = pickRandom(matches);
this.store.logAction({
kind: "gif_call",
guildId: trace.guildId,
channelId: trace.channelId,
userId: trace.userId,
content: normalizedQuery,
metadata: {
provider: "giphy",
query: normalizedQuery,
source: trace.source || "unknown",
rating: this.rating,
returnedResults: matches.length,
used: Boolean(selected),
gifUrl: selected?.url || null
}
});
return selected || null;
} catch (error) {
this.store.logAction({
kind: "gif_error",
guildId: trace.guildId,
channelId: trace.channelId,
userId: trace.userId,
content: String(error?.message || error),
metadata: {
provider: "giphy",
query: normalizedQuery,
rating: this.rating,
source: trace.source || "unknown"
}
});
throw error;
}
}
async searchGiphy({ query, limit }) { const endpoint = new URL(GIPHY_SEARCH_API_URL); endpoint.searchParams.set("api_key", this.apiKey); endpoint.searchParams.set("q", query); endpoint.searchParams.set("limit", String(clamp(Number(limit) || 10, 1, 25))); endpoint.searchParams.set("rating", this.rating); endpoint.searchParams.set("lang", "en"); endpoint.searchParams.set("bundle", "messaging_non_clips");
const response = await fetch(endpoint, {
method: "GET",
headers: {
"user-agent": GIF_USER_AGENT,
accept: "application/json"
},
signal: AbortSignal.timeout(GIF_TIMEOUT_MS)
});
if (!response.ok) {
throw new Error(`GIPHY HTTP ${response.status}`);
}
let payload = null;
try {
payload = await response.json();
} catch {
throw new Error("GIPHY returned invalid JSON.");
}
const rawItems = Array.isArray(payload?.data) ? payload.data : [];
const seenUrls = new Set();
const items = [];
for (const row of rawItems) {
const media = row?.images ?? {};
const url = sanitizeHttpsUrl(
media?.fixed_height?.url ||
media?.downsized?.url ||
media?.original?.url ||
media?.preview_gif?.url ||
""
);
if (!url || seenUrls.has(url)) continue;
seenUrls.add(url);
items.push({
id: String(row?.id || ""),
title: sanitizeExternalText(row?.title || "", 140),
url,
pageUrl: sanitizeHttpsUrl(row?.url || row?.bitly_url || "")
});
}
return items;
} }
function normalizeGiphyRating(rawValue) { const normalized = String(rawValue || "pg-13") .trim() .toLowerCase(); return GIPHY_ALLOWED_RATINGS.has(normalized) ? normalized : "pg-13"; }
function pickRandom(items) { if (!Array.isArray(items) || !items.length) return null; return items[Math.floor(Math.random() * items.length)]; }
function sanitizeExternalText(text, maxLen) { return normalizeWhitespaceText(text, { maxLen: clamp(Number(maxLen) || 120, 1, 5000) }); }
function sanitizeHttpsUrl(rawUrl) { const input = String(rawUrl || "").trim(); if (!input) return "";
try { const parsed = new URL(input); if (parsed.protocol !== "https:") return ""; parsed.hash = ""; return parsed.toString(); } catch { return ""; } }
