diff --git a/.env.example b/.env.example index 21aa629..52c99ef 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,9 @@ ADMIN_IDS=YOUR_TELEGRAM_USER_ID +# AUTOFIX_SECRET: HMAC-SHA256 signing secret shared with the Autofix AI sender. +# Generate: openssl rand -hex 32 +AUTOFIX_SECRET= +# ENDPOINT_PORT: port for the runewager-endpoint companion service (backend.js). +ENDPOINT_PORT=3001 AFFILIATE_SOURCE=GambleCodez ANNOUNCE_CHANNEL="-1002648883359" BOTPRIVACYMODE="disabled" diff --git a/backend.js b/backend.js new file mode 100644 index 0000000..e7c1996 --- /dev/null +++ b/backend.js @@ -0,0 +1,250 @@ +'use strict'; +// ============================================================================= +// Runewager Endpoint — companion HTTP service on port 3001 +// +// Responsibilities: +// - Autofix AI webhook receiver (POST /autofix/webhook) +// - GET /autofix/test — signature self-test +// - GET /health — basic liveness +// - GET /health/full — detailed diagnostic snapshot +// - Telegram admin broadcast helper (sendAdmin) +// - Structured request/event logging to logs/backend.log +// - Graceful SIGTERM/SIGINT shutdown +// +// This service does NOT run Telegram polling (index.js owns that). +// ============================================================================= + +require('dotenv').config(); + +const express = require('express'); +const crypto = require('crypto'); +const fs = require('fs'); +const https = require('https'); +const os = require('os'); +const path = require('path'); +const { globalThrottle, stats: rlStats } = require('./rateLimiter'); + +// ─── Config ────────────────────────────────────────────────────────────────── + +const PORT = parseInt(process.env.ENDPOINT_PORT ?? '3001', 10); +const AUTOFIX_SECRET = process.env.AUTOFIX_SECRET ?? ''; +const TG_TOKEN = process.env.TELEGRAM_BOT_TOKEN ?? process.env.BOT_TOKEN ?? ''; +const ADMIN_CHAT_IDS = (process.env.ADMIN_IDS ?? '') + .split(',').map(s => s.trim()).filter(Boolean); + +// ─── Structured Logging ────────────────────────────────────────────────────── + +const LOG_DIR = path.join(__dirname, 'logs'); +const LOG_FILE = path.join(LOG_DIR, 'backend.log'); +const ERR_FILE = path.join(LOG_DIR, 'backend-error.log'); + +try { + if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); +} catch (e) { + console.error(`[backend] Cannot create log directory ${LOG_DIR}: ${e.message}`); +} + +const _logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' }); +const _errStream = fs.createWriteStream(ERR_FILE, { flags: 'a' }); + +const logger = { + _entry(level, eventType, msg, extra) { + return { ts: new Date().toISOString(), level, eventType, msg, ...extra }; + }, + _write(streams, obj) { + const line = JSON.stringify(obj) + '\n'; + for (const s of streams) s.write(line); + }, + info(eventType, msg, extra = {}) { + this._write([_logStream], this._entry('info', eventType, msg, extra)); + }, + error(eventType, msg, extra = {}) { + this._write([_logStream, _errStream], this._entry('error', eventType, msg, extra)); + }, + event(eventType, msg, extra = {}) { + this._write([_logStream], this._entry('event', eventType, msg, extra)); + }, +}; + +// ─── Telegram Admin Broadcast ───────────────────────────────────────────────── + +/** + * Send a plain-text message to every chat ID listed in ADMIN_CHAT_IDS. + * Failures per-chat are swallowed so one bad ID never blocks the others. + * @param {string} msg + * @returns {Promise} + */ +function sendAdmin(msg) { + if (!TG_TOKEN || ADMIN_CHAT_IDS.length === 0) return Promise.resolve(); + + const sendOne = (chatId) => + globalThrottle(() => new Promise((resolve) => { + const body = JSON.stringify({ chat_id: chatId, text: String(msg) }); + const req = https.request({ + hostname: 'api.telegram.org', + path: `/bot${TG_TOKEN}/sendMessage`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }, () => resolve()); + req.setTimeout(10_000, () => { req.destroy(); resolve(); }); + req.on('error', () => resolve()); + req.write(body); + req.end(); + })); + + return Promise.all(ADMIN_CHAT_IDS.map(sendOne)).then(() => {}); +} + +// ─── HMAC Signature Verification ───────────────────────────────────────────── + +/** + * Verify an HMAC-SHA256 signature against the raw request body. + * Accepts both bare hex and "sha256=" prefixed forms. + * Uses timing-safe comparison to prevent timing attacks. + * @param {Buffer} rawBody + * @param {string} signature + * @returns {boolean} + */ +function verifySignature(rawBody, signature) { + if (!AUTOFIX_SECRET) return false; + const expected = crypto + .createHmac('sha256', AUTOFIX_SECRET) + .update(rawBody) + .digest('hex'); + const sig = (signature ?? '').replace(/^sha256=/, ''); + if (sig.length !== expected.length) return false; + try { + return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex')); + } catch { + return false; + } +} + +// ─── Express App ────────────────────────────────────────────────────────────── + +const app = express(); + +// requestId middleware — every request gets a short unique ID for log correlation +app.use((req, _res, next) => { + req.id = crypto.randomBytes(5).toString('hex'); + next(); +}); + +// Capture raw body for HMAC verification on /autofix routes before JSON parsing +app.use('/autofix', express.raw({ type: '*/*', limit: '1mb' })); +app.use(express.json({ limit: '1mb' })); + +// ─── GET /health ────────────────────────────────────────────────────────────── + +app.get('/health', (req, res) => { + logger.info('http.health', 'Health check', { requestId: req.id }); + res.json({ + ok: true, + service: 'runewager-endpoint', + uptimeSec: Math.floor(process.uptime()), + }); +}); + +// ─── GET /health/full ───────────────────────────────────────────────────────── + +app.get('/health/full', (req, res) => { + const mem = process.memoryUsage(); + const [load1] = os.loadavg(); + logger.info('http.health.full', 'Full health check', { requestId: req.id }); + res.json({ + ok: true, + service: 'runewager-endpoint', + uptimeSec: Math.floor(process.uptime()), + nodeVersion: process.version, + memoryMB: { + rss: +(mem.rss / 1_048_576).toFixed(1), + heapUsed: +(mem.heapUsed / 1_048_576).toFixed(1), + }, + cpuLoad: +load1.toFixed(2), + activeTelegramPolling: false, // polling lives in index.js + autofixWebhookRegistered: Boolean(AUTOFIX_SECRET), + adminChatIdsConfigured: ADMIN_CHAT_IDS.length, + rateLimiter: rlStats(), + }); +}); + +// ─── GET /autofix/test ──────────────────────────────────────────────────────── + +app.get('/autofix/test', (req, res) => { + const timestamp = new Date().toISOString(); + const payload = JSON.stringify({ event: 'test', ts: timestamp, requestId: req.id }); + const sig = AUTOFIX_SECRET + ? 'sha256=' + crypto.createHmac('sha256', AUTOFIX_SECRET).update(payload).digest('hex') + : ''; + const signatureValid = AUTOFIX_SECRET ? verifySignature(Buffer.from(payload), sig) : false; + + logger.event('autofix.test', 'Signature self-test', { requestId: req.id, signatureValid }); + res.json({ ok: true, signatureValid, timestamp, requestId: req.id, secretConfigured: Boolean(AUTOFIX_SECRET) }); +}); + +// ─── POST /autofix/webhook ──────────────────────────────────────────────────── + +app.post('/autofix/webhook', async (req, res) => { + const requestId = req.id; + try { + const sig = req.headers['x-autofix-signature'] ?? req.headers['x-hub-signature-256'] ?? ''; + const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(JSON.stringify(req.body ?? {})); + + if (!AUTOFIX_SECRET || !verifySignature(rawBody, sig)) { + const reason = !AUTOFIX_SECRET ? 'AUTOFIX_SECRET not configured' : 'Signature mismatch'; + logger.error('autofix.webhook', `${reason} — request rejected`, { requestId }); + return res.status(401).json({ ok: false, error: 'Invalid signature', requestId }); + } + + let payload; + try { payload = JSON.parse(rawBody.toString('utf8')); } catch { payload = {}; } + + const eventName = payload.event ?? payload.action ?? 'unknown'; + logger.event('autofix.webhook', 'Autofix event received', { requestId, event: eventName }); + + await sendAdmin(`🤖 Autofix [${requestId}]: ${eventName}`); + + res.json({ ok: true, requestId, received: true }); + } catch (err) { + const summary = err?.message ?? String(err); + logger.error('autofix.webhook', 'Handler error', { requestId, error: summary }); + await sendAdmin(`⚠️ Autofix webhook error [${requestId}]: ${summary}`).catch(() => {}); + res.status(500).json({ ok: false, error: 'Internal server error', requestId }); + } +}); + +// ─── Start ──────────────────────────────────────────────────────────────────── + +const server = app.listen(PORT, '127.0.0.1', () => { + logger.info('server.start', 'runewager-endpoint listening', { port: PORT }); +}); + +// ─── Graceful Shutdown ──────────────────────────────────────────────────────── + +function shutdown(signal) { + logger.info('server.shutdown', `${signal} received — shutting down gracefully`); + server.close(() => { + logger.info('server.shutdown', 'HTTP server closed — exiting cleanly'); + process.exit(0); + }); + // Force-exit after 10 s if connections don't drain + setTimeout(() => { + logger.error('server.shutdown', 'Graceful shutdown timeout — forcing exit'); + process.exit(1); + }, 10_000).unref(); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + +process.on('uncaughtException', err => { + logger.error('process.uncaughtException', err.message, { stack: err.stack }); + sendAdmin(`💀 runewager-endpoint crash: ${err.message}`).finally(() => process.exit(1)); +}); + +process.on('unhandledRejection', reason => { + logger.error('process.unhandledRejection', String(reason)); +}); diff --git a/index.js b/index.js index 0f1e2f6..10220bb 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,7 @@ const path = require('path'); const os = require('os'); const crypto = require('crypto'); const { exec, execFile, spawn } = require('child_process'); +const telegramSafe = require('./telegramSafe'); // ========================= // Config @@ -225,6 +226,12 @@ const COPY = { const bot = new Telegraf(BOT_TOKEN); +// Patch bot.telegram methods to route through the global rate limiter. +// Because ctx.telegram IS bot.telegram (same object reference in Telegraf v4), +// this single call covers ctx.reply(), ctx.answerCbQuery(), ctx.editMessageText(), +// ctx.telegram.sendMessage(), and every direct bot.telegram.*() call in this file. +// Must be called BEFORE any bot.use() middleware so the patch is always in effect. +telegramSafe.init(bot); // Callback-query hygiene middleware: if a callback handler sends a new reply, // remove the source callback card first so menus do not stack. diff --git a/package.json b/package.json index 5010143..019bb71 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ }, "scripts": { "start": "node index.js", + "endpoint": "node backend.js", + "endpoint:check": "node --check backend.js", "prod": "./prod-run.sh", "health": "./health-check.sh", "health:verbose": "./health-check.sh --verbose", @@ -25,6 +27,7 @@ }, "dependencies": { "dotenv": "^16.6.1", + "express": "^4.21.2", "telegraf": "^4.16.3" } } diff --git a/rateLimiter.js b/rateLimiter.js new file mode 100644 index 0000000..24c826a --- /dev/null +++ b/rateLimiter.js @@ -0,0 +1,142 @@ +'use strict'; +// ============================================================================= +// rateLimiter.js — Telegram API rate limiter +// +// Enforces: +// - Global: ~35ms between any two Telegram API calls (~28 calls/sec) +// - Per-chat: 1100ms between consecutive messages to the same chat +// +// Exports: +// enqueue(chatId, fn) — serialize fn for chatId with per-chat + global limits +// globalThrottle(fn) — global limit only (for answerCallbackQuery etc.) +// +// Compatible with Node.js >=20 and the Telegraf v4 / Runewager architecture. +// ============================================================================= + +// ─── Tuning constants ───────────────────────────────────────────────────────── + +const GLOBAL_GAP_MS = 35; // ≤28 req/sec globally (Telegram hard limit: 30/sec) +const CHAT_GAP_MS = 1100; // 1 msg/sec per chat (Telegram hard limit: 1/sec) +const API_CALL_TIMEOUT_MS = 15000; // max time for a single Telegram API call + +// ─── State ──────────────────────────────────────────────────────────────────── + +// Per-chat tail: chatId (string) → tail Promise of the queue chain +const _chatQueues = new Map(); + +// Global rate chain — all API calls are threaded through this single chain +let _globalTail = Promise.resolve(); +let _globalLast = 0; // timestamp (ms) of the most recent API call start + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const _delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ─── Global rate slot ───────────────────────────────────────────────────────── + +/** + * Route fn() through the global rate-limit chain. + * Guarantees at least GLOBAL_GAP_MS between any two API calls globally. + * + * @param {() => Promise} fn + * @returns {Promise} + */ +function _globalSlot(fn) { + // Build a promise that resolves/rejects with fn()'s result, but only + // after our turn in the global chain. + let resolve, reject; + const resultP = new Promise((res, rej) => { resolve = res; reject = rej; }); + + _globalTail = _globalTail + .then(async () => { + const wait = Math.max(0, _globalLast + GLOBAL_GAP_MS - Date.now()); + if (wait > 0) await _delay(wait); + _globalLast = Date.now(); + try { + resolve(await Promise.race([ + Promise.resolve().then(fn), + _delay(API_CALL_TIMEOUT_MS).then(() => { + throw new Error(`rateLimiter: API call timed out after ${API_CALL_TIMEOUT_MS}ms`); + }), + ])); + } + catch (err) { reject(err); } + }) + .catch(() => {}); // never let the global chain break — errors propagate via resultP + + return resultP; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Enqueue fn for a specific chatId. + * + * - Per-chat serialization: only one API call at a time per chatId. + * - Per-chat gap: CHAT_GAP_MS enforced between consecutive calls. + * - Global throttle: also goes through _globalSlot (GLOBAL_GAP_MS). + * + * The caller receives their result as soon as fn() settles; the next queued + * call for this chatId then waits CHAT_GAP_MS before starting. + * + * @param {string|number|null} chatId - Telegram chat/user ID (null → global queue) + * @param {() => Promise} fn - Async Telegram API call + * @returns {Promise} + */ +function enqueue(chatId, fn) { + const key = chatId != null ? String(chatId) : '_global'; + const prevTail = _chatQueues.get(key) ?? Promise.resolve(); + + // Expose fn()'s result to our caller immediately when it settles + let resolve, reject; + const resultP = new Promise((res, rej) => { resolve = res; reject = rej; }); + + // New tail: wait for prev, acquire a global slot, call fn, then wait CHAT_GAP_MS + const newTail = prevTail + .then(async () => { + try { resolve(await _globalSlot(fn)); } + catch (err) { reject(err); } + // Enforce per-chat gap AFTER the call completes. + // This delays the TAIL (= the next call's start), NOT the caller's result. + await _delay(CHAT_GAP_MS); + }) + .catch(() => {}); // never break the per-chat chain + + _chatQueues.set(key, newTail); + + // Cleanup: remove stale Map entries once this tail resolves + newTail.then(() => { + if (_chatQueues.get(key) === newTail) _chatQueues.delete(key); + }); + + return resultP; +} + +/** + * Global throttle only — no per-chat serialization. + * + * Use for calls where the chatId is irrelevant or meaningless (e.g. + * answerCallbackQuery, which has a hard 10-second deadline and must not + * be held behind a long per-chat queue). + * + * @param {() => Promise} fn + * @returns {Promise} + */ +function globalThrottle(fn) { + return _globalSlot(fn); +} + +// ─── Diagnostics (for /health/full endpoint) ────────────────────────────────── + +/** + * Return a snapshot of rate-limiter internals for observability. + * @returns {{ pendingQueues: number, globalLastMs: number }} + */ +function stats() { + return { + pendingQueues: _chatQueues.size, + globalLastMs: _globalLast, + }; +} + +module.exports = { enqueue, globalThrottle, stats }; diff --git a/runewager-endpoint.service b/runewager-endpoint.service new file mode 100644 index 0000000..eaf2e61 --- /dev/null +++ b/runewager-endpoint.service @@ -0,0 +1,49 @@ +# ============================================================================= +# runewager-endpoint.service — Autofix webhook + admin bridge +# +# Companion service to runewager.service (main Telegram bot). +# Runs backend.js on port 3001 (ENDPOINT_PORT). +# +# Renamed from runewager-bot.service. +# DO NOT merge with runewager.service — these are independent processes. +# +# To install on a fresh VPS: +# cp runewager-endpoint.service /etc/systemd/system/ +# systemctl daemon-reload +# systemctl enable --now runewager-endpoint +# ============================================================================= + +[Unit] +Description=Runewager Endpoint — Autofix webhook and admin bridge +After=network-online.target runewager.service +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/var/www/html/Runewager +ExecStart=/usr/bin/node /var/www/html/Runewager/backend.js +User=runewager +Group=runewager +EnvironmentFile=/var/www/html/Runewager/.env +Environment=NODE_ENV=production + +Restart=always +RestartSec=5 +StartLimitIntervalSec=60 +StartLimitBurst=5 + +KillSignal=SIGTERM +TimeoutStopSec=15 + +StandardOutput=append:/var/www/html/Runewager/logs/backend.log +StandardError=append:/var/www/html/Runewager/logs/backend-error.log +ReadWritePaths=/var/www/html/Runewager/logs +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +NoNewPrivileges=true +UMask=0077 +LimitNOFILE=16384 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/runewager_redeploy.sh b/scripts/runewager_redeploy.sh index 241c962..9e5f577 100755 --- a/scripts/runewager_redeploy.sh +++ b/scripts/runewager_redeploy.sh @@ -121,7 +121,9 @@ _stop_bot_service() { fi # Stop legacy service names so they can't restart and steal port ${BOT_PORT} - # while this redeploy is in progress. Failures are non-fatal. + # while this redeploy is in progress. + # NOTE: runewager-endpoint is NOT listed here — it's a companion service on a + # separate port (3001) and is handled by _restart_companion_services() below. local _legacy for _legacy in "runewager-bot" "runewager_bot"; do @@ -142,7 +144,12 @@ _stop_bot_service() { if (( DRY_RUN )); then warn "[DRY-RUN] would stop legacy service '${_legacy}'" else - systemctl stop "$_legacy" 2>/dev/null || true + if ! systemctl stop "$_legacy" 2>/dev/null; then + warn "Failed to stop legacy service '${_legacy}'" + fi + if systemctl is-active --quiet "$_legacy" 2>/dev/null; then + fail "Legacy service '${_legacy}' is still active after stop — may conflict on port ${BOT_PORT}" + fi fi fi fi @@ -362,8 +369,41 @@ _start_bot_service() { run "Start ${BOT_SERVICE}" systemctl start "$BOT_SERVICE" } +# ─────────────────── STEP 5b: RESTART COMPANION SERVICES ──────────────────── + +_restart_companion_services() { + local _companions=("runewager-endpoint") + local _svc + for _svc in "${_companions[@]}"; do + if systemctl list-unit-files --type=service 2>/dev/null | grep -q "^${_svc}.service"; then + if (( DRY_RUN )); then + warn "[DRY-RUN] would restart companion service '${_svc}'" + else + systemctl restart "$_svc" 2>/dev/null \ + && ok "Companion service '${_svc}' restarted" \ + || warn "Failed to restart companion service '${_svc}' (non-fatal)" + fi + fi + done +} + # ─────────────────────────────── STEP 6: HEALTH CHECK ──────────────────────── +# Pretty-print TEXT using FORMATTER_ARRAY (bash array), falling back to raw +# output when the formatter is absent or exits non-zero. Hoisted here so it +# can be reused by any step, not just the health-check poll loop. +_show_payload() { + local text="$1"; shift + local -a fmt=("$@") # formatter as an array — safe with multi-word args + local formatted + formatted=$(printf '%s' "$text" | "${fmt[@]}" 2>/dev/null) + if [[ -n "$formatted" ]]; then + printf '%s\n' "$formatted" | head -20 | sed 's/^/ /' + else + printf '%s\n' "$text" | head -20 | sed 's/^/ /' + fi +} + _health_check() { step "6/8 — Health check (waiting up to ${HEALTH_TIMEOUT}s)" @@ -380,11 +420,9 @@ _health_check() { ok "Bot healthy at ${HEALTH_URL} (${elapsed}s)" local _payload; _payload=$(curl -sS --max-time 3 "$HEALTH_URL" 2>/dev/null) if command -v python3 >/dev/null 2>&1; then - echo "$_payload" | python3 -m json.tool 2>/dev/null | head -20 | sed 's/^/ /' \ - || echo "$_payload" | head -20 | sed 's/^/ /' + _show_payload "$_payload" python3 -m json.tool elif command -v jq >/dev/null 2>&1; then - echo "$_payload" | jq . 2>/dev/null | head -20 | sed 's/^/ /' \ - || echo "$_payload" | head -20 | sed 's/^/ /' + _show_payload "$_payload" jq . else printf '%s\n' "$_payload" | head -20 | sed 's/^/ /' fi @@ -443,7 +481,7 @@ _summary() { echo "" echo -e " ${_bold}Service states:${_reset}" - for svc in "$BOT_SERVICE" "rw_cpu_guard"; do + for svc in "$BOT_SERVICE" "rw_cpu_guard" "runewager-endpoint"; do local state state=$(systemctl is-active "$svc" 2>/dev/null) [[ -z "$state" ]] && state="not-found" @@ -517,6 +555,7 @@ main() { _ensure_tooltips _install_guard _start_bot_service + _restart_companion_services _health_check || true _start_forensics _summary diff --git a/scripts/rw_cpu_guard.sh b/scripts/rw_cpu_guard.sh index 6ea0338..687f5f3 100755 --- a/scripts/rw_cpu_guard.sh +++ b/scripts/rw_cpu_guard.sh @@ -678,7 +678,12 @@ _run_forensics() { fi # ── 1-second CPU tracer (catches sub-8s spikes the guard daemon may miss) ─ - echo "timestamp,pid,ppid,cpu%,mem%,args" > "$trace" + local trace_tmp + trace_tmp=$(mktemp "${trace}.XXXXXX") || { echo "ERROR: mktemp failed for trace file" >&2; return 1; } + printf '%s\n' "timestamp,pid,ppid,cpu%,mem%,args" > "$trace_tmp" \ + || { rm -f "$trace_tmp"; echo "ERROR: failed to initialize trace header" >&2; return 1; } + mv -f "$trace_tmp" "$trace" \ + || { rm -f "$trace_tmp"; echo "ERROR: failed to publish trace file" >&2; return 1; } ( while true; do # Use 'args' (not 'cmd') so the full command path and every argument # are captured. Fields $5..$NF are joined into one value so commands diff --git a/telegramSafe.js b/telegramSafe.js new file mode 100644 index 0000000..2d7aa45 --- /dev/null +++ b/telegramSafe.js @@ -0,0 +1,279 @@ +'use strict'; +// ============================================================================= +// telegramSafe.js — Safe, rate-limited Telegram API wrappers +// +// Usage: +// const tg = require('./telegramSafe'); +// tg.init(bot); // call once, immediately after new Telegraf(TOKEN) +// +// After init(): +// - ALL Telegram API calls — including ctx.reply(), ctx.answerCbQuery(), +// ctx.editMessageText(), ctx.telegram.sendMessage(), bot.telegram.*() — +// are automatically rate-limited. No other changes needed. +// - The explicit exports (sendMessage, editMessageText, etc.) are provided +// for modules that do not have a Telegraf context (e.g. backend.js). +// +// Rate limits enforced (see rateLimiter.js): +// - ~28 calls/sec globally +// - 1 message/sec per chat +// - 429 responses trigger exponential-backoff retry (up to 3 retries) +// ============================================================================= + +const { enqueue, globalThrottle } = require('./rateLimiter'); + +// ─── State ──────────────────────────────────────────────────────────────────── + +let _bot = null; +let _inited = false; + +// ─── 429 / transient-error retry ───────────────────────────────────────────── + +/** + * Wrap an API call with retry logic for 429 Too Many Requests and transient + * network errors. Respects Telegram's `retry_after` field when present. + * + * @param {() => Promise} fn + * @param {number} [maxRetries=3] + * @returns {Promise} + */ +async function _withRetry(fn, maxRetries = 3) { + let lastErr; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + const code = err?.response?.error_code ?? err?.code; + + if (code === 429) { + const retryAfterMs = ((err?.response?.parameters?.retry_after) ?? 5) * 1000; + if (attempt < maxRetries) { + const wait = retryAfterMs + 250; // small extra margin + console.warn( + `[telegramSafe] 429 rate-limited — waiting ${wait}ms before retry` + + ` (attempt ${attempt + 1}/${maxRetries})` + ); + await new Promise((r) => setTimeout(r, wait)); + continue; + } + } + + // Transient network errors (ETIMEDOUT, ECONNRESET, ECONNREFUSED) + const isTransient = ( + err?.code === 'ETIMEDOUT' + || err?.code === 'ECONNRESET' + || err?.code === 'ECONNREFUSED' + || err?.response?.error_code === 502 + ); + if (isTransient && attempt < maxRetries) { + const wait = (2 ** attempt) * 500; // 500ms, 1000ms, 2000ms + console.warn(`[telegramSafe] Transient error (${err.code ?? err.response?.error_code}) — retry in ${wait}ms`); + await new Promise((r) => setTimeout(r, wait)); + continue; + } + + // Non-retryable error — log and re-throw + console.error( + `[telegramSafe] API error (code=${code}, msg=${err?.response?.description ?? err?.message})` + ); + throw err; + } + } + throw lastErr; +} + +// ─── init() — patches bot.telegram in place ─────────────────────────────────── + +/** + * Patch bot.telegram methods to route through the rate limiter. + * + * Must be called ONCE, immediately after: + * const bot = new Telegraf(BOT_TOKEN); + * + * Because ctx.telegram IS bot.telegram (same object reference in Telegraf v4), + * this single patch covers ALL call paths: + * ctx.reply() → bot.telegram.sendMessage ✓ + * ctx.replyWithPhoto() → bot.telegram.sendPhoto ✓ + * ctx.editMessageText() → bot.telegram.editMessageText ✓ + * ctx.answerCbQuery() → bot.telegram.answerCbQuery ✓ + * bot.telegram.sendMessage → directly patched ✓ + * ctx.telegram.sendMessage → same object, directly patched ✓ + * + * @param {import('telegraf').Telegraf} bot + */ +function init(bot) { + if (_inited) return; + if (!bot || !bot.telegram) { + throw new Error('[telegramSafe] init(bot) requires a valid Telegraf instance'); + } + + const tg = bot.telegram; + + // ── sendMessage ──────────────────────────────────────────────────────────── + const _sendMessage = tg.sendMessage.bind(tg); + tg.sendMessage = (chatId, text, extra) => + _withRetry(() => enqueue(chatId, () => _sendMessage(chatId, text, extra))); + + // ── editMessageText ──────────────────────────────────────────────────────── + // Telegraf Telegram#editMessageText(chatId, messageId, inlineMessageId, text, extra) + const _editMessageText = tg.editMessageText.bind(tg); + tg.editMessageText = (chatId, messageId, inlineMessageId, text, extra) => + _withRetry(() => enqueue(chatId, () => + _editMessageText(chatId, messageId, inlineMessageId, text, extra) + )); + + // ── answerCbQuery (Telegraf v4 method name on Telegram class) ───────────── + // ctx.answerCbQuery() → tg.answerCbQuery(callbackQueryId, text, showAlert, extra) + // Must NOT go through per-chat queue — has a hard 10-second deadline. + if (typeof tg.answerCbQuery === 'function') { + const _answerCbQuery = tg.answerCbQuery.bind(tg); + tg.answerCbQuery = (callbackQueryId, ...args) => + _withRetry(() => globalThrottle(() => _answerCbQuery(callbackQueryId, ...args))); + } + // Also patch answerCallbackQuery in case it exists as an alias + if (typeof tg.answerCallbackQuery === 'function' && tg.answerCallbackQuery !== tg.answerCbQuery) { + const _answerCallbackQuery = tg.answerCallbackQuery.bind(tg); + tg.answerCallbackQuery = (callbackQueryId, ...args) => + _withRetry(() => globalThrottle(() => _answerCallbackQuery(callbackQueryId, ...args))); + } + + // ── sendPhoto ────────────────────────────────────────────────────────────── + if (typeof tg.sendPhoto === 'function') { + const _sendPhoto = tg.sendPhoto.bind(tg); + tg.sendPhoto = (chatId, photo, extra) => + _withRetry(() => enqueue(chatId, () => _sendPhoto(chatId, photo, extra))); + } + + // ── sendDocument ─────────────────────────────────────────────────────────── + if (typeof tg.sendDocument === 'function') { + const _sendDocument = tg.sendDocument.bind(tg); + tg.sendDocument = (chatId, document, extra) => + _withRetry(() => enqueue(chatId, () => _sendDocument(chatId, document, extra))); + } + + // ── sendAnimation (used by ctx.replyWithAnimation) ───────────────────────── + if (typeof tg.sendAnimation === 'function') { + const _sendAnimation = tg.sendAnimation.bind(tg); + tg.sendAnimation = (chatId, animation, extra) => + _withRetry(() => enqueue(chatId, () => _sendAnimation(chatId, animation, extra))); + } + + // ── sendSticker ──────────────────────────────────────────────────────────── + if (typeof tg.sendSticker === 'function') { + const _sendSticker = tg.sendSticker.bind(tg); + tg.sendSticker = (chatId, sticker, extra) => + _withRetry(() => enqueue(chatId, () => _sendSticker(chatId, sticker, extra))); + } + + // ── forwardMessage ───────────────────────────────────────────────────────── + if (typeof tg.forwardMessage === 'function') { + const _forwardMessage = tg.forwardMessage.bind(tg); + tg.forwardMessage = (chatId, fromChatId, messageId, extra) => + _withRetry(() => enqueue(chatId, () => _forwardMessage(chatId, fromChatId, messageId, extra))); + } + + // ── callApi (safety net for all methods not individually patched above) ──── + // Telegraf routes every API method through callApi internally. By patching it + // here we cover methods like deleteMessage, getChatMember, setMyCommands, etc. + // The managed-methods set prevents double-wrapping: when an individually-patched + // method (sendMessage, etc.) calls this.callApi internally, we skip the extra + // layer and pass through to the original. + if (typeof tg.callApi === 'function') { + const _MANAGED = new Set([ + 'sendMessage', 'editMessageText', + 'answerCbQuery', 'answerCallbackQuery', + 'sendPhoto', 'sendDocument', 'sendAnimation', 'sendSticker', 'forwardMessage', + ]); + const _callApi = tg.callApi.bind(tg); + tg.callApi = (method, data, signal) => { + if (_MANAGED.has(method)) return _callApi(method, data, signal); + return _withRetry(() => globalThrottle(() => _callApi(method, data, signal))); + }; + } + + _bot = bot; + _inited = true; + console.log('[telegramSafe] bot.telegram methods patched — all API calls are now rate-limited'); +} + +// ─── Explicit exports (for use without a ctx) ───────────────────────────────── + +/** + * Rate-limited sendMessage. + * @param {string|number} chatId + * @param {string} text + * @param {object} [options] + * @returns {Promise} + */ +function sendMessage(chatId, text, options = {}) { + _assertInited(); + return _bot.telegram.sendMessage(chatId, text, options); +} + +/** + * Rate-limited editMessageText. + * @param {string|number} chatId + * @param {number} messageId + * @param {string} text + * @param {object} [options] + * @returns {Promise} + */ +function editMessageText(chatId, messageId, text, options = {}) { + _assertInited(); + return _bot.telegram.editMessageText(chatId, messageId, undefined, text, options); +} + +/** + * Rate-limited answerCallbackQuery. + * Uses globalThrottle (not per-chat queue) to respect the 10-second deadline. + * @param {string} callbackId + * @param {object} [options] + * @returns {Promise} + */ +function answerCallbackQuery(callbackId, options = {}) { + _assertInited(); + const { text, showAlert, extra } = options || {}; + // Prefer answerCbQuery (Telegraf v4 name); fall back to answerCallbackQuery alias. + // Both methods are already patched in init() — call directly, no extra wrapping. + const method = typeof _bot.telegram.answerCbQuery === 'function' + ? _bot.telegram.answerCbQuery.bind(_bot.telegram) + : _bot.telegram.answerCallbackQuery.bind(_bot.telegram); + return method(callbackId, text, showAlert, extra); +} + +/** + * Rate-limited sendPhoto. + * @param {string|number} chatId + * @param {string|object} photo + * @param {object} [options] + */ +function sendPhoto(chatId, photo, options = {}) { + _assertInited(); + return _bot.telegram.sendPhoto(chatId, photo, options); +} + +/** + * Rate-limited sendDocument. + * @param {string|number} chatId + * @param {string|object} document + * @param {object} [options] + */ +function sendDocument(chatId, document, options = {}) { + _assertInited(); + return _bot.telegram.sendDocument(chatId, document, options); +} + +function _assertInited() { + if (!_inited || !_bot) { + throw new Error('[telegramSafe] init(bot) must be called before using wrapper functions'); + } +} + +module.exports = { + init, + sendMessage, + editMessageText, + answerCallbackQuery, + sendPhoto, + sendDocument, +};