Badge cards: seven AI artworks for the hunting achievements, the hunter's name stamped on the ribbon server-side (ffmpeg), public share pages /b/<who>/<id> with OG cards, badge strip + share modal + unlock celebration on the board, Telegram photo post gated by OUTBOUND

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-19 20:31:20 -05:00
parent 6338af9efe
commit 8eecc7c0cb
19 changed files with 156 additions and 14 deletions
+41 -3
View File
@@ -19,11 +19,13 @@ const sso = require('./lib/sso');
const missions = require('./lib/missions');
const rewards = require('./lib/rewards');
const social = require('./lib/social');
const badge = require('./lib/badge');
const faucet = require('./lib/faucet');
const PORT = Number(process.env.PORT || 3000);
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const PUBLIC_DIR = path.join(__dirname, 'public');
badge.init({ publicDir: PUBLIC_DIR, dataDir: process.env.DATA_DIR || path.join(__dirname, 'data') });
const CURTAIN = String(process.env.CURTAIN || '').trim();
const ADMIN_KEY = String(process.env.ADMIN_KEY || '').trim();
const SITE = String(process.env.SITE_URL || 'https://polhunter.com').replace(/\/+$/, '');
@@ -41,11 +43,22 @@ async function telegram(text) {
const body = JSON.stringify(Object.assign({ chat_id: chat, text, parse_mode: 'HTML', disable_web_page_preview: true }, topic ? { message_thread_id: Number(topic) } : {}));
try { const r = await fetch('https://api.telegram.org/bot' + tok + '/sendMessage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }); return r.ok; } catch (e) { return false; }
}
async function telegramPhoto(file, caption) {
if (!outbound()) return false; // the gate
const tok = process.env.HUNT_TG_TOKEN, chat = process.env.HUNT_TG_CHAT, topic = process.env.HUNT_TG_TOPIC;
if (!tok || !chat || !file) return false;
try {
const fd = new FormData(); fd.append('chat_id', chat); fd.append('caption', caption); fd.append('parse_mode', 'HTML'); if (topic) fd.append('message_thread_id', String(topic));
fd.append('photo', new Blob([fs.readFileSync(file)], { type: 'image/jpeg' }), 'badge.jpg');
const r = await fetch('https://api.telegram.org/bot' + tok + '/sendPhoto', { method: 'POST', body: fd }); return r.ok;
} catch (e) { return false; }
}
const fmt = n => Number(n).toLocaleString('en-US', { maximumFractionDigits: 4 });
async function notify(kind, p) {
if (kind === 'paid') return telegram('\u{1F3AF} <b>PolHunter</b> · ' + (p.username ? '@' + p.username : '#' + p.memberId) + ' found it on ' + p.site + ' and got <b>' + fmt(p.pol) + ' POL</b> · <a href="' + explorer() + '/tx/' + p.tx + '">verify</a>\n<a href="' + SITE + '">Hunt yours</a>');
if (kind === 'low') return telegram('⚠️ <b>PolHunter faucet is low</b>: ' + fmt(p.balance) + ' POL left in ' + p.address + ' (alert threshold ' + fmt(p.threshold) + '). Top up from Receiver B.');
if (kind === 'failed') return telegram('❌ <b>PolHunter</b> · drip to #' + p.memberId + ' failed: ' + p.error);
if (kind === 'badge') { const b = social.BADGES.find(x => x.id === p.id); if (!b) return false; const who = social.nameOf(p.me); const file = await badge.render(p.id, p.me.username || '#' + p.me.memberId); const cap = '\u{1F3C6} <b>PolHunter</b> \u00b7 <b>' + (p.me.username ? '@' + p.me.username : '#' + p.me.memberId) + '</b> unlocked <b>' + b.name + '</b>: ' + b.why + '\n' + SITE + '/b/' + who + '/' + p.id; return file ? telegramPhoto(file, cap) : telegram(cap); }
if (kind === 'prize') { const medal = ['\u{1F947}', '\u{1F948}', '\u{1F949}']; return telegram('\u{1F3C6} <b>PolHunter weekly prizes</b> for the week of ' + p.week + '\n' + p.winners.map(w => (medal[w.rank - 1] || '#' + w.rank) + ' ' + w.who + ' \u00b7 ' + w.finds + ' finds \u00b7 <b>' + fmt(w.prizePol) + ' POL</b>').join('\n') + '\n<a href="' + SITE + '/leaders">Leaderboard</a>'); }
}
// Marty's rule (2026-09-19): when the day's pool is spent, say so; claims reopen at midnight Central
@@ -117,6 +130,27 @@ const server = http.createServer(async (req, res) => {
return json(res, r.error ? 403 : 200, r, cors);
}
if (p === '/embed.js') return sendFile(res, path.join(PUBLIC_DIR, 'embed.js'), { 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*' });
// badge cards and their share pages are posted around the web: they pass the curtain
if (/^\/badges\/badge-[a-z]+\.jpg$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' });
{ const bm = /^\/badge-img\/([a-z0-9_.-]{1,40})\/([a-z]+)\.jpg$/.exec(p) || /^\/b\/([a-z0-9_.-]{1,40})\/([a-z]+)$/.exec(p);
if (bm) {
const who = bm[1], id = bm[2]; const m = social.memberByName(who); const b = social.BADGES.find(x => x.id === id);
if (!m || !b || !social.badgesFor(m.memberId).some(x => x.id === id)) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); }
if (p.startsWith('/badge-img/')) { const file = await badge.render(id, m.username || '#' + m.memberId); return sendFile(res, file || path.join(PUBLIC_DIR, 'badges', 'badge-' + id + '.jpg'), { 'Cache-Control': 'public, max-age=3600' }); }
const esc = t => String(t || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const url = SITE + '/b/' + who + '/' + id, img = SITE + '/badge-img/' + who + '/' + id + '.jpg', ref = SITE + '/?r=' + encodeURIComponent(m.username || m.memberId);
const title = m.who + ' unlocked ' + b.name + ' on PolHunter', desc = b.name + ': ' + b.why + '. PolHunter pays random drips of POL for finding your code on our sites, on chain, with a link to prove it.';
const share = encodeURIComponent(title + ' ' + url);
const html = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' + esc(title) + '</title><meta name="description" content="' + esc(desc) + '"><link rel="canonical" href="' + url + '"><meta name="robots" content="noindex">'
+ '<meta property="og:type" content="website"><meta property="og:site_name" content="PolHunter"><meta property="og:title" content="' + esc(title) + '"><meta property="og:description" content="' + esc(desc) + '"><meta property="og:url" content="' + url + '"><meta property="og:image" content="' + img + '"><meta property="og:image:width" content="1080"><meta property="og:image:height" content="1080">'
+ '<meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="' + esc(title) + '"><meta name="twitter:description" content="' + esc(desc) + '"><meta name="twitter:image" content="' + img + '"><link rel="stylesheet" href="/style.css?v=10"></head>'
+ '<body><div class="wrap"><header class="top"><a class="mark" href="/"><span class="coin"></span>PolHunter</a><nav class="nav"><a class="btn ghost sm" href="/leaders">Leaderboard</a><a class="btn sm" href="' + ref + '">Hunt yours</a></nav></header>'
+ '<div class="bp"><img src="' + img + '" alt="' + esc(b.name) + ' badge for ' + esc(m.who) + '"><h1>' + esc(m.who) + ' unlocked <em>' + esc(b.name) + '</em></h1><p>' + esc(b.why.charAt(0).toUpperCase() + b.why.slice(1)) + '. PolHunter pays random drips of POL for finding your code on our sites, straight to your wallet, on chain.</p>'
+ '<a class="btn" href="' + ref + '">Hunt yours</a><div class="sharebtns" style="justify-content:center;margin-top:16px"><a class="btn ghost sm" target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=' + share + '">X</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent(title) + '">Telegram</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(url) + '">Facebook</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://wa.me/?text=' + share + '">WhatsApp</a></div>'
+ '<p class="small" style="margin-top:26px">Rewards are for completed missions, not income. Cryptocurrency involves risk of loss.</p></div></div></body></html>';
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=300' }); return res.end(html);
}
}
// the coin on the code pill, fetched by mission-site visitors who hold no curtain pass
if (p === '/img/coin-sm.png' || p === '/img/coin.png' || p === '/img/og.jpg' || /^\/promo\/polhunter-\d+x\d+\.(jpg|png)$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' });
@@ -145,7 +179,7 @@ const server = http.createServer(async (req, res) => {
// ---- public
if (p === '/api/config') { const ref = refOf(req); return json(res, 200, { name: 'PolHunter', signupsOpen: signupsOpen(), reward: rewards.settings(), iapUrl: 'https://instantadpay.com/my', explorer: explorer(), ref, joinUrl: ref ? 'https://instantadpay.com/join/' + encodeURIComponent(ref) + '?from=polhunter' : 'https://instantadpay.com/?from=polhunter' }); }
if (p === '/api/leaders') { const per = ['week', 'month', 'all'].includes(u.searchParams.get('period')) ? u.searchParams.get('period') : 'week'; return json(res, 200, { period: per, rows: social.leaderboard(per, 25) }, { 'Cache-Control': 'public, max-age=60' }); }
if (p === '/api/badges') return json(res, 200, { badges: social.BADGES });
if (p === '/api/badges') return json(res, 200, { badges: social.BADGES.map(b => Object.assign({ art: '/badges/badge-' + b.id + '.jpg' }, b)) });
if (p === '/api/prizes') return json(res, 200, social.prizes(), { 'Cache-Control': 'public, max-age=60' });
if (p === '/leaders') return sendFile(res, path.join(PUBLIC_DIR, 'leaders.html'));
if (p === '/promo') return sendFile(res, path.join(PUBLIC_DIR, 'promo.html'));
@@ -158,7 +192,7 @@ const server = http.createServer(async (req, res) => {
const done = new Set(rewards.mine(me.memberId).map(x => x.missionId));
const wdone = me.wallet ? new Set(store.read('payouts', []).filter(x => x.wallet && x.wallet.toLowerCase() === me.wallet && x.status !== 'failed').map(x => x.missionId)) : new Set();
return json(res, 200, { me: { memberId: me.memberId, username: me.username, wallet: me.wallet }, missions: missions.forMember(me.memberId).map(m => Object.assign(pubMission(m), { done: done.has(m.id) || wdone.has(m.id) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn }, pool: rewards.pool(),
badges: social.badgesFor(me.memberId), rank: { week: social.rankOf(me.memberId, 'week'), month: social.rankOf(me.memberId, 'month'), all: social.rankOf(me.memberId, 'all') },
badges: social.badgesFor(me.memberId), badgeCards: (() => { const got = new Set(social.badgesFor(me.memberId).map(b => b.id)); const who = social.nameOf(me); return social.BADGES.map(b => ({ id: b.id, name: b.name, icon: b.icon, why: b.why, art: '/badges/badge-' + b.id + '.jpg', earned: got.has(b.id), page: got.has(b.id) ? SITE + '/b/' + who + '/' + b.id : null, image: got.has(b.id) ? SITE + '/badge-img/' + who + '/' + b.id + '.jpg' : null })); })(), rank: { week: social.rankOf(me.memberId, 'week'), month: social.rankOf(me.memberId, 'month'), all: social.rankOf(me.memberId, 'all') },
share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) } });
}
if (p === '/api/my/start' && req.method === 'POST') {
@@ -181,8 +215,12 @@ const server = http.createServer(async (req, res) => {
const m = missions.get(c.rec.missionId); if (!m) return json(res, 404, { error: 'That mission is gone.' });
if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' });
{ const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); }
const before = new Set(social.badgesFor(me.memberId).map(b => b.id));
const g = rewards.grant(me, m); if (g.error) return json(res, 400, g);
return json(res, 200, { ok: true, pol: g.rec.pol, queued: g.queued, message: g.queued ? 'Found it. Today’s POL is spoken for, so yours is queued and pays out next.' : 'Found it. ' + fmt(g.rec.pol) + ' POL is on its way to your wallet.' });
// achievements unlocked by this find: told to the board, posted to Telegram (gated)
const unlocked = social.badgesFor(me.memberId).filter(b => !before.has(b.id));
for (const b of unlocked) notify('badge', { me, id: b.id }).catch(() => {});
return json(res, 200, { ok: true, pol: g.rec.pol, queued: g.queued, unlocked: unlocked.map(b => b.id), message: g.queued ? 'Found it. Today’s POL is spoken for, so yours is queued and pays out next.' : 'Found it. ' + fmt(g.rec.pol) + ' POL is on its way to your wallet.' });
}
return json(res, 404, { error: 'No such call.' });
}