Chat: a GIF picker kept family-friendly, sharing a place, and Signal conversations in the Chat list

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 12:13:26 -04:00
1 parent 3ddf9942c2
commit 584a95753d
16 files changed
+200 -13

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
// GIFs for Family Chat, from Tenor or GIPHY (whichever key the admin gives: TENOR_API_KEY or GIPHY_API_KEY). The hub does all the talking to the service, so phones never contact it:
// searches come back through here, previews are fetched here, and a GIF that is sent is downloaded and kept in the chat like any other picture.
// Everything is kept family-friendly, for everybody: only the services' G-rated results are asked for, words that could bring up adult material are refused in a search and in what comes
// back (title and tags), and only GIFs this server itself listed can be sent. A filter like this is a strong guard, not a promise: a sender or an admin can delete anything that slips through.
const TENOR = () => (process.env.TENOR_URL || 'https://tenor.googleapis.com').replace(/\/+$/, ''), GIPHY = () => (process.env.GIPHY_URL || 'https://api.giphy.com').replace(/\/+$/, '');
const provider = () => process.env.TENOR_API_KEY ? 'tenor' : process.env.GIPHY_API_KEY ? 'giphy' : '';
const enabled = () => !!provider();
// whole words (and stems) that are never searched for and never shown
const BLOCK = ['sex', 'sexy', 'sexual', 'sexually', 'sexier', 'sexiest', 'porn', 'porno', 'pornography', 'pornographic', 'xxx', 'nsfw', 'nude', 'nudes', 'nudity', 'naked', 'topless', 'bottomless', 'boob', 'boobs', 'boobies', 'breast', 'breasts', 'tit', 'tits', 'titty', 'titties', 'nipple', 'nipples', 'cleavage', 'butt', 'butts', 'booty', 'twerk', 'twerking', 'thong', 'thongs', 'lingerie', 'bikini', 'bikinis', 'underwear', 'panties', 'bra', 'bras', 'strip', 'stripper', 'strippers', 'striptease', 'erotic', 'erotica', 'fetish', 'kink', 'kinky', 'bdsm', 'hentai', 'orgasm', 'orgasms', 'penis', 'vagina', 'dick', 'dicks', 'cock', 'cocks', 'pussy', 'pussies', 'cum', 'cumming', 'horny', 'slut', 'sluts', 'whore', 'hooker', 'hookers', 'masturbate', 'masturbation', 'blowjob', 'handjob', 'anal', 'milf', 'onlyfans', 'playboy', 'adult', 'adults', 'lust', 'seduce', 'seductive', 'foreplay', 'grope', 'groping', 'fuck', 'fucking', 'fucker', 'fucked', 'shit', 'bitch', 'bitches', 'asshole', 'ass', 'drugs', 'cocaine', 'marijuana', 'gore', 'suicide'];
const BLOCKED = new Set(BLOCK);
/** Does this text (a search, a title, tags) contain a word that is not allowed? Whole words only, so "class" and "assistant" are fine. */
const unsafe = text => (String(text || '').toLowerCase().match(/[a-z0-9]+/g) || []).some(w => BLOCKED.has(w) || BLOCKED.has(w.replace(/s$/, '')));
// only GIFs this server has just listed can be sent or previewed (so the download address cannot be chosen by the sender)
const known = new Map(); // url -> expiry
const remember = urls => { const t = Date.now() + 3600e3; for (const u of urls) known.set(u, t); if (known.size > 4000) for (const [k, v] of known) if (v < Date.now()) known.delete(k); };
const isKnown = u => (known.get(u) || 0) > Date.now();
const MEDIA = /^https:\/\/(media[0-9]*\.tenor\.com|media[0-9]*\.giphy\.com|i\.giphy\.com|c\.tenor\.com)\//i;
const mediaOk = u => { try { const x = new URL(u); return MEDIA.test(u) || String(process.env.GIF_MEDIA_HOSTS || '').split(',').filter(Boolean).includes(x.host); } catch { return false; } };
async function getJson(url) {
const r = await fetch(url, { headers: { accept: 'application/json', 'user-agent': 'NE-Hub/1.0 (family chat)' }, signal: AbortSignal.timeout(8000) });
if (r.status === 403 || r.status === 401) throw Object.assign(new Error('The GIF service refused the key. Check it in the stack settings.'), { code: 502 });
if (r.status === 429) throw Object.assign(new Error('The GIF service is busy. Try again in a minute.'), { code: 429 });
if (!r.ok) throw Object.assign(new Error('The GIF service did not answer (' + r.status + ')'), { code: 502 });
return r.json();
}
/** Search (or, with no words, what is popular). Returns { items: [{ title, w, h, preview, url }], next }. */
async function search(q, pos) {
q = String(q || '').trim().slice(0, 60);
if (q && unsafe(q)) throw Object.assign(new Error('That search is not available. Try something else.'), { code: 400 });
const n = 24; let items = [], next = '';
if (provider() === 'tenor') {
const key = encodeURIComponent(process.env.TENOR_API_KEY), base = `${TENOR()}/v2/${q ? 'search?q=' + encodeURIComponent(q) + '&' : 'featured?'}key=${key}&client_key=nehub&limit=${n}&contentfilter=high&media_filter=tinygif,gif${pos ? '&pos=' + encodeURIComponent(pos) : ''}`;
const j = await getJson(base); next = j.next || '';
items = (j.results || []).map(x => { const t = (x.media_formats || {}).tinygif, g = (x.media_formats || {}).gif || t; return t && g ? { title: x.content_description || '', tags: (x.tags || []).join(' '), w: (t.dims || [])[0] || 0, h: (t.dims || [])[1] || 0, preview: t.url, url: g.url } : null; });
} else if (provider() === 'giphy') {
const off = Math.max(0, parseInt(pos, 10) || 0), key = encodeURIComponent(process.env.GIPHY_API_KEY), base = `${GIPHY()}/v1/gifs/${q ? 'search?q=' + encodeURIComponent(q) + '&' : 'trending?'}api_key=${key}&limit=${n}&offset=${off}&rating=g&bundle=messaging_non_clips`;
const j = await getJson(base); next = (j.data || []).length >= n ? String(off + n) : '';
items = (j.data || []).filter(x => !x.rating || x.rating === 'g').map(x => { const t = (x.images || {}).fixed_width_small || (x.images || {}).fixed_width, g = (x.images || {}).fixed_width || (x.images || {}).original; return t && g && t.url && g.url ? { title: x.title || '', tags: '', w: +t.width || 0, h: +t.height || 0, preview: t.url, url: g.url } : null; });
} else throw Object.assign(new Error('GIFs are not set up on this server'), { code: 503 });
items = items.filter(x => x && mediaOk(x.preview) && mediaOk(x.url) && !unsafe(x.title) && !unsafe(x.tags)).map(({ tags, ...x }) => x);
remember(items.flatMap(x => [x.preview, x.url])); return { items, next };
}
/** The bytes of a GIF this server listed (a preview or the full one), size-limited. */
async function fetchBytes(url, max = 8 * 1024 * 1024) {
if (!isKnown(url) || !mediaOk(url)) throw Object.assign(new Error('That GIF is not available'), { code: 400 });
const r = await fetch(url, { headers: { 'user-agent': 'NE-Hub/1.0 (family chat)' }, signal: AbortSignal.timeout(15000), redirect: 'error' });
if (!r.ok) throw Object.assign(new Error('The GIF could not be downloaded'), { code: 502 });
const type = String(r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); if (!/^image\/(gif|webp|png|jpeg)$/.test(type)) throw Object.assign(new Error('That is not a picture'), { code: 502 });
const buf = Buffer.from(await r.arrayBuffer()); if (buf.length > max) throw Object.assign(new Error('That GIF is too large'), { code: 413 }); return { buf, type };
}
module.exports = { enabled, provider, search, fetchBytes, unsafe, isKnown, remember };