/** * Loiter plugin for Discord (or any Node.js) bots. * * Monetize your bot's "working…" / loading messages. Fail-safe: every method * swallows errors — if Loiter is unreachable your bot behaves as before. * * Setup (one time): node loiter-discord.js register [serverUrl] [refCode] * Usage: * const { Loiter } = require('./loiter-discord'); * const sb = Loiter.load(); // reads ~/.loiter/discord_bot.json * const line = await sb.line(); // "Sponsored · …" or null * // append to your embed footer / status message, then when it's done: * await sb.report(displaySeconds); */ const fs = require('fs'); const os = require('os'); const path = require('path'); const DEFAULT_SERVER = 'http://127.0.0.1:8787'; const DEFAULT_CONF = path.join(os.homedir(), '.loiter', 'discord_bot.json'); const SECONDS_PER_IMPRESSION = 5; const REFRESH_MS = 600_000; async function api(server, method, p, body, params) { let url = server + p; if (params) url += '?' + new URLSearchParams(params); const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(4000), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); } class Loiter { constructor(server, deviceKey) { this.server = server; this.deviceKey = deviceKey; this.ads = []; this.fetched = 0; this.i = 0; this.shown = new Map(); } static load(conf = DEFAULT_CONF) { const c = JSON.parse(fs.readFileSync(conf, 'utf8')); return new Loiter(c.server, c.device_key); } async line() { try { if (Date.now() - this.fetched > REFRESH_MS || !this.ads.length) { const r = await api(this.server, 'GET', '/api/serve', null, { device_key: this.deviceKey, n: 6 }); this.ads = r.ads || []; this.fetched = Date.now(); } if (!this.ads.length) return null; const ad = this.ads[this.i++ % this.ads.length]; this.shown.set(ad.ad_id, (this.shown.get(ad.ad_id) || 0) + 1); return `Sponsored · ${ad.text}${ad.url ? ' → ' + ad.url : ''}`; } catch { return null; } } async report(displaySeconds) { try { const total = Math.floor(Math.max(0, displaySeconds) / SECONDS_PER_IMPRESSION); if (!total || !this.shown.size) { this.shown.clear(); return; } const weights = [...this.shown.values()].reduce((a, b) => a + b, 0); const entries = [...this.shown.entries()].sort((a, b) => a[0] - b[0]); const items = []; let left = total; entries.forEach(([adId, w], j) => { let n = j < entries.length - 1 ? Math.floor((total * w) / weights) : left; n = Math.min(n, left); if (n > 0) items.push({ ad_id: adId, count: n }); left -= n; }); this.shown.clear(); if (items.length) { await api(this.server, 'POST', '/api/impressions', { device_key: this.deviceKey, items, active_seconds: Math.floor(displaySeconds) }); } } catch { this.shown.clear(); } } } async function register(argv) { const server = argv[0] || DEFAULT_SERVER; const ref = argv[1] || null; const r = await api(server, 'POST', '/api/register', { platform: 'discord-bot', ref }); fs.mkdirSync(path.dirname(DEFAULT_CONF), { recursive: true }); fs.writeFileSync(DEFAULT_CONF, JSON.stringify({ server, device_key: r.device_key, ref_code: r.ref_code }, null, 2), { mode: 0o600 }); console.log(`Registered. Config: ${DEFAULT_CONF}\nReferral code: ${r.ref_code}`); } if (require.main === module) { if (process.argv[2] === 'register') register(process.argv.slice(3)); else console.log('Usage: node loiter-discord.js register [serverUrl] [refCode]'); }