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
BIN
View File
Binary file not shown.
+1
View File
@@ -81,6 +81,7 @@ FACTS:
- HOLDING TANK (Members > My line > Holding tank card): free members who joined with no sponsor wait there; a member who has switched on payouts AND bought their own $20+ package can Adopt one (first come, max 2 open adoptions, 7-day window; if the person never links a wallet or buys, they fall back into the tank; a person can be adopted twice at most). Adopting sets the sponsor, opens a chat and emails the member; their first purchase then binds to the adopter on-chain. Members can also "Release to tank" one of their own free referrals (pay it forward), but NOT someone they adopted less than 3 days ago: an adoption is a commitment, and a dropped adoption still counts toward that person's two-adoption lifetime limit. Releases are posted to the feed and Telegram like pickups. Admin sees the tank under Members. - HOLDING TANK (Members > My line > Holding tank card): free members who joined with no sponsor wait there; a member who has switched on payouts AND bought their own $20+ package can Adopt one (first come, max 2 open adoptions, 7-day window; if the person never links a wallet or buys, they fall back into the tank; a person can be adopted twice at most). Adopting sets the sponsor, opens a chat and emails the member; their first purchase then binds to the adopter on-chain. Members can also "Release to tank" one of their own free referrals (pay it forward), but NOT someone they adopted less than 3 days ago: an adoption is a commitment, and a dropped adoption still counts toward that person's two-adoption lifetime limit. Releases are posted to the feed and Telegram like pickups. Admin sees the tank under Members.
- DAILY CLAIM STREAK: finishing the daily ad set and claiming pays 5 credits on day 1, 7 on day 2, 10 from day 3, and 25 on every 7th consecutive day; miss a day and it restarts. After the set, verified visits (up to 20 a day, 1 credit each) keep earning, and the credits are meant to be spent on a campaign. - DAILY CLAIM STREAK: finishing the daily ad set and claiming pays 5 credits on day 1, 7 on day 2, 10 from day 3, and 25 on every 7th consecutive day; miss a day and it restarts. After the set, verified visits (up to 20 a day, 1 credit each) keep earning, and the credits are meant to be spent on a campaign.
- BLOG (public, instantadpay.com/blog): Marty's coaching and teaching articles on building a line, advertising that pays, and daily habits; each article has its own page and can be shared; RSS at /blog/feed.xml. Members who want to write their own articles: not offered today. - BLOG (public, instantadpay.com/blog): Marty's coaching and teaching articles on building a line, advertising that pays, and daily habits; each article has its own page and can be shared; RSS at /blog/feed.xml. Members who want to write their own articles: not offered today.
- ACHIEVEMENT BADGES ON TELEGRAM (2026-09-13): when a member unlocks Spark/Surge/Circuit/Nexus, their personalised badge image (username on the ribbon) is posted automatically to the team's Telegram payments topic and the main group, once per badge; the Overview badge card also has a 'Post to Telegram' button for badges unlocked earlier, and 'Share' downloads the image.
- HOLDING TANK ALERTS: when new members land in the tank, a note at the top of every member's Overview names them (usernames) and a post goes to the team's Telegram payments topic; adopt from My line > Holding tank (your own $20 package required). - HOLDING TANK ALERTS: when new members land in the tank, a note at the top of every member's Overview names them (usernames) and a post goes to the team's Telegram payments topic; adopt from My line > Holding tank (your own $20 package required).
- LEGACY WELCOME (former Faucet Wave / Tier One Ads members): they join through instantadpay.com/from/faucetwave or instantadpay.com/from/tieroneads and, if their email is on the legacy list, welcome-back credits are added automatically at signup (former advertisers 500, former earners 150; once per person; credits, not POL). They land in the holding tank like any member who joins without a sponsor. - LEGACY WELCOME (former Faucet Wave / Tier One Ads members): they join through instantadpay.com/from/faucetwave or instantadpay.com/from/tieroneads and, if their email is on the legacy list, welcome-back credits are added automatically at signup (former advertisers 500, former earners 150; once per person; credits, not POL). They land in the holding tank like any member who joins without a sponsor.
- PROMO CODES: partner site owners get a reusable code; a member redeems it on a join link (?promo=CODE) or in the Overview box "Have a promo code?" and receives free ad credits (amount set per code by the admin, one use per account; codes can cap uses or expire). Credits, not POL. - PROMO CODES: partner site owners get a reusable code; a member redeems it on a join link (?promo=CODE) or in the Overview box "Have a promo code?" and receives free ad credits (amount set per code by the admin, one use per account; codes can cap uses or expire). Credits, not POL.
+44 -2
View File
@@ -87,17 +87,58 @@
return '<div class="badge-a' + (got ? '' : ' locked') + '">' return '<div class="badge-a' + (got ? '' : ' locked') + '">'
+ '<img class="badge-img" src="' + b.img + '" alt="' + b.label + ' badge" loading="lazy">' + '<img class="badge-img" src="' + b.img + '" alt="' + b.label + ' badge" loading="lazy">'
+ '<div class="bl">' + b.label + '</div><div class="bs">' + b.sub + '</div>' + '<div class="bl">' + b.label + '</div><div class="bs">' + b.sub + '</div>'
+ (got ? '<button class="share" data-badge="' + b.key + '">Share</button>' : '<div class="bs">🔒 locked</div>') + '</div>'; + (got ? '<button class="share" data-badge="' + b.key + '">Share</button> <button class="share" data-badgepost="' + b.key + '">Post to Telegram</button>' : '<div class="bs">🔒 locked</div>') + '</div>';
}).join(''); }).join('');
strip.querySelectorAll('[data-badge]').forEach(btn => strip.querySelectorAll('[data-badge]').forEach(btn =>
btn.addEventListener('click', () => shareBadge(btn.dataset.badge, d))); btn.addEventListener('click', () => shareBadge(btn.dataset.badge, d)));
// celebrate anything newly granted this load strip.querySelectorAll('[data-badgepost]').forEach(btn =>
btn.addEventListener('click', () => postBadge(btn.dataset.badgepost, d, btn)));
fetch('/api/my/badge-posted').then(r => r.json()).then(r => (r.posted || []).forEach(k => { const b = strip.querySelector('[data-badgepost="' + k + '"]'); if (b) { b.textContent = 'Posted ✓'; b.disabled = true; } })).catch(() => {});
// celebrate anything newly granted this load, and post the branded badge to the team channels (Marty, 2026-09-13)
if (d.milestonesGranted && d.milestonesGranted.length) { if (d.milestonesGranted && d.milestonesGranted.length) {
const total = d.milestonesGranted.reduce((s, g) => s + g.credited, 0); const total = d.milestonesGranted.reduce((s, g) => s + g.credited, 0);
const names = d.milestonesGranted.map(g => (BADGES.find(b => b.key === g.key) || {}).label).filter(Boolean).join(', '); const names = d.milestonesGranted.map(g => (BADGES.find(b => b.key === g.key) || {}).label).filter(Boolean).join(', ');
IAP.status('🏆 Achievement unlocked: ' + names + ' — +' + total + ' bonus credits!', 'ok'); IAP.status('🏆 Achievement unlocked: ' + names + ' — +' + total + ' bonus credits!', 'ok');
d.milestonesGranted.forEach((g, i) => setTimeout(() => postBadge(g.key, d, strip.querySelector('[data-badgepost="' + g.key + '"]')), 800 + i * 1500));
} }
} }
// draw the badge with the member's name on the ribbon; cb(canvas) or cb(null)
function composeBadge(key, d, cb) {
const b = BADGES.find(x => x.key === key); if (!b) return cb(null);
const who = d.username ? d.username : d.memberId ? 'member #' + d.memberId : '';
const img = new Image();
img.onload = () => {
const c = document.createElement('canvas'); c.width = img.width; c.height = img.height;
const x = c.getContext('2d');
x.drawImage(img, 0, 0);
if (who) {
x.textAlign = 'center'; x.textBaseline = 'middle';
x.font = 'bold ' + Math.round(c.width * 0.055) + 'px Sora, "Segoe UI", sans-serif';
const y = c.height * (b.ribbonY || 0.75);
x.lineJoin = 'round'; x.lineWidth = Math.max(3, Math.round(c.width * 0.008));
x.strokeStyle = 'rgba(4,20,15,.9)'; x.strokeText(who, c.width / 2, y);
x.fillStyle = '#ffd15c'; x.fillText(who, c.width / 2, y);
}
cb(c);
};
img.onerror = () => cb(null);
img.src = b.img;
}
async function postBadge(key, d, btn) {
if (btn && btn.disabled) return;
if (btn) { btn.disabled = true; btn.textContent = 'Posting…'; }
composeBadge(key, d, c => {
if (!c) { if (btn) { btn.disabled = false; btn.textContent = 'Post to Telegram'; } return; }
c.toBlob(async bl => {
try {
const r = await (await fetch('/api/my/badge-post?key=' + encodeURIComponent(key), { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: bl })).json();
if (r.error) throw new Error(r.error);
if (btn) { btn.textContent = 'Posted ✓'; btn.disabled = true; }
if (!r.already) IAP.status('Your badge is posted in the team channels.', 'ok');
} catch (e) { if (btn) { btn.disabled = false; btn.textContent = 'Post to Telegram'; } IAP.status(e.message || 'Could not post the badge.', 'bad'); }
}, 'image/jpeg', 0.86);
});
}
// compose a shareable badge image on a canvas (zero-dep) and download it // compose a shareable badge image on a canvas (zero-dep) and download it
// composite the ornate badge template with the member's @username on the ribbon // composite the ornate badge template with the member's @username on the ribbon
function shareBadge(key, d) { function shareBadge(key, d) {
@@ -296,6 +337,7 @@
// site-wide activity (not about me): joins, tank, adoptions, purchases, payouts, qualifications // site-wide activity (not about me): joins, tank, adoptions, purchases, payouts, qualifications
if (ev.type === 'Joined') { communityToast('👋 ' + ev.name + ' just joined' + (ev.tank ? ' and is waiting for a sponsor in the holding tank' : '')); liveRefresh(); return; } if (ev.type === 'Joined') { communityToast('👋 ' + ev.name + ' just joined' + (ev.tank ? ' and is waiting for a sponsor in the holding tank' : '')); liveRefresh(); return; }
if (ev.type === 'Adopted') { communityToast('🤝 ' + ev.sponsor + ' picked up ' + ev.member + ' from the holding tank'); liveRefresh(); return; } if (ev.type === 'Adopted') { communityToast('🤝 ' + ev.sponsor + ' picked up ' + ev.member + ' from the holding tank'); liveRefresh(); return; }
if (ev.type === 'Badge') { communityToast('🏆 ' + ev.member + ' unlocked ' + ev.label); return; }
if (ev.type === 'Released') { communityToast('🪣 ' + ev.sponsor + ' returned ' + ev.member + ' to the holding tank'); liveRefresh(); return; } if (ev.type === 'Released') { communityToast('🪣 ' + ev.sponsor + ' returned ' + ev.member + ' to the holding tank'); liveRefresh(); return; }
const mine = MYID && (ev.recipientId === MYID || ev.toId === MYID || ev.sponsorId === MYID || ev.skippedId === MYID || ev.buyerId === MYID); const mine = MYID && (ev.recipientId === MYID || ev.toId === MYID || ev.sponsorId === MYID || ev.skippedId === MYID || ev.buyerId === MYID);
if (!mine) { if (!mine) {
+1 -1
View File
@@ -915,7 +915,7 @@
<script src="/assets/common.js?v=20260913a"></script> <script src="/assets/common.js?v=20260913a"></script>
<script src="/assets/wallet.js?v=20260911a"></script> <script src="/assets/wallet.js?v=20260911a"></script>
<script src="/assets/promo.js?v=20260911a"></script> <script src="/assets/promo.js?v=20260911a"></script>
<script src="/assets/my.js?v=20260913f"></script> <script src="/assets/my.js?v=20260913g"></script>
<script src="/assets/chat.js?v=20260907l"></script> <script src="/assets/chat.js?v=20260907l"></script>
</body> </body>
</html> </html>
+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) ---- // ---- live feed (SSE) ----
const feedClients = new Set(); const feedClients = new Set();
function pushFeed(ev) { 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.' }); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, await tank.view(s.email)); 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') { if (p === '/api/my/tank/adopt' && req.method === 'POST') {
const s = await auth.fromRequest(req); const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });