Achievement badges: personalised badge image auto-posted to the Telegram payments topic and main group on unlock (once per badge), Post to Telegram button, feed toast

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-13 16:55:43 -05:00
parent 0c58b80184
commit 964a02a342
5 changed files with 101 additions and 3 deletions
+55
View File
@@ -650,6 +650,27 @@ async function telegramSend(chatId, text, threadId) {
});
}
// photo post (multipart) for the achievement badges (Marty, 2026-09-13); same bot, sendPhoto only
async function telegramSendPhoto(chatId, jpeg, caption, threadId) {
const sc = siteConfig();
if (!sc.telegramBotToken || !chatId || !jpeg) return false;
const B = '----iapbadge' + crypto.randomBytes(8).toString('hex');
const field = (n, v) => Buffer.from('--' + B + '\r\nContent-Disposition: form-data; name="' + n + '"\r\n\r\n' + v + '\r\n');
const parts = [field('chat_id', String(chatId)), field('caption', caption), field('parse_mode', 'HTML')];
if (threadId) parts.push(field('message_thread_id', String(Number(threadId))));
parts.push(Buffer.from('--' + B + '\r\nContent-Disposition: form-data; name="photo"; filename="badge.jpg"\r\nContent-Type: image/jpeg\r\n\r\n'), jpeg, Buffer.from('\r\n--' + B + '--\r\n'));
const body = Buffer.concat(parts);
return new Promise((resolve) => {
const rq = https.request({ hostname: 'api.telegram.org', path: '/bot' + sc.telegramBotToken + '/sendPhoto', method: 'POST', headers: { 'Content-Type': 'multipart/form-data; boundary=' + B, 'Content-Length': body.length }, timeout: 20000 }, res => {
let out = ''; res.on('data', c => out += c); res.on('end', () => resolve(res.statusCode === 200));
});
rq.on('error', () => resolve(false)); rq.on('timeout', () => { rq.destroy(); resolve(false); }); rq.end(body);
});
}
const BADGE_META = { payouts: ['Spark', 'payouts switched on'], firstBuyer: ['Surge', 'first qualifying buyer'], level2: ['Circuit', 'two qualifying buyers, level 2 open'], level3: ['Nexus', 'five qualifying buyers, fully qualified'] };
const BADGE_LOG = () => path.join(DATA_DIR, 'badge-posts.json');
function badgeLog() { try { return JSON.parse(fs.readFileSync(BADGE_LOG(), 'utf8')); } catch (e) { return {}; } }
// ---- live feed (SSE) ----
const feedClients = new Set();
function pushFeed(ev) {
@@ -1258,6 +1279,40 @@ const server = http.createServer(async (req, res) => {
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, await tank.view(s.email));
}
// -- achievement badge -> Telegram (payments topic + the main group), once per badge per member.
// The browser composes the personalised image (canvas, same as Share) and posts it here.
if (p === '/api/my/badge-post' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const key = String(u.searchParams.get('key') || '');
if (!BADGE_META[key]) return json(res, 400, { error: 'Unknown badge.' });
if (!/^image\/jpeg/.test(String(req.headers['content-type'] || ''))) return json(res, 400, { error: 'Send the badge as a JPEG.' });
let jpeg; try { jpeg = await readRaw(req, 1.5 * 1024 * 1024); } catch (e) { return json(res, 413, { error: 'Image too large.' }); }
if (!jpeg || jpeg.length < 2000 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return json(res, 400, { error: 'That is not a JPEG.' });
const held = await ads.milestonesOf(s.email);
if (!held.includes(key)) return json(res, 400, { error: 'You have not unlocked that badge yet.' });
const log = badgeLog(); const mine = log[s.email] || {};
if (mine[key]) return json(res, 200, { ok: true, already: true });
const a = await accounts.byEmail(s.email);
const who = a && a.username ? '@' + a.username : (a && a.memberId ? 'member #' + a.memberId : 'a member');
const link = a && a.username ? 'instantadpay.com/join/' + a.username : 'instantadpay.com';
const [label, sub] = BADGE_META[key];
const caption = '\u{1F3C6} <b>InstantAdPay</b> \u00b7 <b>' + who.replace(/[<>&]/g, '') + '</b> unlocked <b>' + label + '</b>: ' + sub + '\n' + link;
const sc = siteConfig(); let sent = 0;
if (sc.telegramBotToken && sc.telegramEchoChatId) {
if (await telegramSendPhoto(sc.telegramEchoChatId, jpeg, caption, sc.telegramEchoTopicId)) sent++; // payments topic
if (String(sc.telegramBadgeGeneral || '1') !== '0' && await telegramSendPhoto(sc.telegramEchoChatId, jpeg, caption, null)) sent++; // the main group (General)
}
mine[key] = { ts: Date.now(), sent }; log[s.email] = mine; try { fs.writeFileSync(BADGE_LOG(), JSON.stringify(log)); } catch (e) {}
pushFeed({ type: 'Badge', member: who, label, ts: Date.now() });
console.log('badge posted', s.email, key, 'sent', sent);
return json(res, 200, { ok: true, sent });
}
if (p === '/api/my/badge-posted' && req.method === 'GET') { // which of my badges are already on Telegram
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, { posted: Object.keys(badgeLog()[s.email] || {}) });
}
if (p === '/api/my/tank/adopt' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });