diff --git a/index.js b/index.js index f914149..e560c9a 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const crypto = require('crypto'); +const { execFile, spawn } = require('child_process'); // ========================= // Config @@ -134,6 +135,21 @@ const userMutationQueue = new Map(); // per-user promise chain for atomic update const processStartedAt = Date.now(); let lastPersistAt = 0; +// Absolute path to the project root (same directory as this file) +const PROJECT_DIR = path.resolve(__dirname); + +/** + * Run a shell command and resolve with { ok, out, err }. + * Never throws β€” callers decide what to do with failures. + */ +function runCmd(cmd, args, cwd, timeoutMs = 60000) { + return new Promise((resolve) => { + execFile(cmd, args, { cwd, timeout: timeoutMs, maxBuffer: 2 * 1024 * 1024 }, (err, stdout, stderr) => { + resolve({ ok: !err, out: (stdout || '').trim(), err: (stderr || (err ? err.message : '')).trim() }); + }); + }); +} + const walkthroughCatalog = buildWalkthroughCatalog(); const giveawayFeatureList = buildGiveawayFeatureList(); @@ -475,7 +491,7 @@ function requireAdmin(ctx) { */ function checkBonusEligibility(user) { if (needsLinkedUsername(user)) { - return { ok: false, reason: 'You must link your Runewager username first. Use /linkrunewager.' }; + return { ok: false, needsUsername: true, reason: 'You must link your Runewager username before requesting the bonus.' }; } // 1. Affiliate check if (user.wagerBonus.affiliateSource !== 'GambleCodez') { @@ -1192,6 +1208,77 @@ bot.command('admin_backup', async (ctx) => { } }); +// ========================= +// /deploy β€” admin-only live deployment +// Pulls latest code, installs deps, then restarts the bot process. +// The bot notifies admins again on startup to confirm the restart landed. +// ========================= +bot.command('deploy', async (ctx) => { + if (!requireAdmin(ctx)) return; + + const ts = () => new Date().toLocaleTimeString('en-GB', { hour12: false }); + + // Step 1 β€” persist state before anything changes + try { persistRuntimeState(); } catch (_) {} + + const status = await ctx.reply( + `πŸš€ *Deployment started* β€” ${ts()}\n\n` + + `β‘  Pulling latest code from origin main…`, + { parse_mode: 'Markdown' }, + ); + const edit = (text) => ctx.telegram + .editMessageText(ctx.chat.id, status.message_id, null, text, { parse_mode: 'Markdown' }) + .catch(() => {}); + + // ── Step 1: git pull ────────────────────────────────────────── + const gitResult = await runCmd('git', ['pull', 'origin', 'main'], PROJECT_DIR, 30000); + const gitLine = gitResult.ok + ? `βœ… *git pull:* \`${gitResult.out.split('\n')[0].slice(0, 200)}\`` + : `⚠️ *git pull failed:* \`${gitResult.err.slice(0, 200)}\``; + + await edit( + `πŸš€ *Deployment in progress* β€” ${ts()}\n\n` + + `${gitLine}\n\nβ‘‘ Installing dependencies…`, + ); + + // ── Step 2: npm ci ──────────────────────────────────────────── + const npmResult = await runCmd('npm', ['ci', '--omit=dev'], PROJECT_DIR, 180000); + const npmLine = npmResult.ok + ? `βœ… *npm ci:* OK` + : `⚠️ *npm ci:* \`${npmResult.err.slice(0, 200)}\``; + + const allOk = gitResult.ok && npmResult.ok; + const summary = allOk ? 'βœ… All steps succeeded' : '⚠️ Some steps had warnings β€” check below'; + + await edit( + `πŸš€ *Deployment summary* β€” ${ts()}\n\n` + + `${gitLine}\n` + + `${npmLine}\n\n` + + `${summary}\n\n` + + `β‘’ Restarting bot now…`, + ); + + // ── Step 3: notify then restart ─────────────────────────────── + // Notify ALL admins so everyone sees the restart is happening + const restartNote = `πŸ” *Bot is restarting* β€” ${ts()}\nYou will receive a βœ… confirmation message when it comes back online.`; + for (const adminId of ADMIN_IDS) { + bot.telegram.sendMessage(adminId, restartNote, { parse_mode: 'Markdown' }).catch(() => {}); + } + + // Allow Telegram to deliver messages before the process dies + setTimeout(() => { + // Prefer systemctl restart (systemd handles clean lifecycle) + const svc = spawn('systemctl', ['restart', 'runewager'], { detached: true, stdio: 'ignore' }); + svc.on('error', () => { + // systemctl unavailable β€” just exit; systemd/nohup will restart us + process.exit(0); + }); + svc.unref(); + // Exit regardless after 2s so the process doesn't linger + setTimeout(() => process.exit(0), 2000); + }, 1500); +}); + // ========================= // /bonus β€” user status view + admin sub-commands // ========================= @@ -1660,6 +1747,40 @@ bot.action('cancel_link', async (ctx) => { await ctx.reply('Link cancelled.', Markup.inlineKeyboard([[Markup.button.callback('⬅️ Main Menu', 'to_main_menu')]])); }); +// Confirm a username that was detected from a free-text message (smart detection path) +bot.action('confirm_smart_username', async (ctx) => { + const user = getUser(ctx); + const action = user.pendingAction; + if (!action || action.type !== 'await_username_confirm') { + await ctx.answerCbQuery('Nothing to confirm.'); + return; + } + const normalized = action.data.username; + const wasLinked = !!(user.runewagerUsername && user.runewagerUsername.trim()); + user.runewagerUsername = normalized; + user.pendingAction = null; + addBadge(user, 'linked_profile'); + if (!wasLinked) user.profileXP += 10; + trackOnboardingProgress(user); + await ctx.answerCbQuery('Username linked!'); + await reactToMessage(ctx, 'πŸ”—'); + const nextStep = getNextOnboardingStep(user); + const nextStepText = nextStep < 5 + ? `\n\n➑️ Next step: ${onboardingStepLabel(nextStep)}` + : '\n\nπŸŽ‰ Onboarding complete! You can now request the 30 SC bonus.'; + await ctx.reply( + `βœ… Runewager account linked!\n\nπŸ‘€ Username: *${normalized}*\n\nYou can now:\nβ€’ Request the 30 SC wager bonus (/bonus)\nβ€’ Join SC giveaways\nβ€’ View full profile (/profile)${nextStepText}`, + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('🎯 Request 30 SC Bonus', 'w30_request_start')], + [Markup.button.callback('πŸ‘€ View Profile', 'menu_profile_action')], + [Markup.button.callback('⬅️ Main Menu', 'to_main_menu')], + ]), + }, + ); +}); + bot.action('menu_join_channel', async (ctx) => { const user = getUser(ctx); user.hasJoinedChannel = true; @@ -1763,6 +1884,23 @@ bot.action('w30_request_start', async (ctx) => { // Run eligibility checks 1–3 (not wager yet β€” wager may need input) const check = checkBonusEligibility(user); + // Username not linked β€” capture it inline then resume the bonus flow + if (!check.ok && check.needsUsername) { + user.pendingAction = { type: 'await_runewager_username', returnTo: 'w30_bonus' }; + await ctx.reply( + 'πŸ”— *One quick step first β€” link your Runewager username*\n\n' + + 'Send your exact Runewager username and we\'ll pick up your bonus request right after.', + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.url('πŸ‘€ Find My Username', LINKS.runewagerProfile)], + [Markup.button.callback('❌ Cancel', 'cancel_link')], + ]), + }, + ); + return; + } + if (!check.ok && !check.needsWager) { await ctx.reply(check.reason); return; @@ -2358,9 +2496,38 @@ bot.on('inline_query', safeStepHandler('inline_query', async (ctx) => { // ========================= bot.on('text', async (ctx) => { const user = getUser(ctx); - if (!user.pendingAction) return; const text = (ctx.message.text || '').trim(); + + // Smart username detection β€” fires when there is NO pending action, the message looks + // like a Runewager username (3-30 chars, alphanumeric/underscore/hyphen/dot, no spaces), + // and the user has not yet linked an account. Lets users type their username naturally + // at any point without needing an explicit prompt first. + if (!user.pendingAction) { + if ( + text && + !text.startsWith('/') && + /^[A-Za-z0-9_.\\-]{3,30}$/.test(text) && + !user.runewagerUsername + ) { + const normalized = normalizeRunewagerUsername(text); + user.pendingAction = { type: 'await_username_confirm', data: { username: normalized } }; + await ctx.reply( + `πŸ‘€ Is *${normalized}* your Runewager username?`, + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('βœ… Yes, link it', 'confirm_smart_username')], + [Markup.button.callback('πŸ”— Enter a different username', 'menu_link_runewager')], + [Markup.button.callback('❌ Cancel', 'cancel_link')], + ]), + }, + ); + return; + } + return; + } + const action = user.pendingAction; // Bug report submission @@ -2402,13 +2569,33 @@ bot.on('text', async (ctx) => { return; } const normalizedUsername = normalizeRunewagerUsername(text); - const wasLinked = user.runewagerUsername && user.runewagerUsername.trim(); + const wasLinked = !!(user.runewagerUsername && user.runewagerUsername.trim()); + const returnTo = action.returnTo || null; user.runewagerUsername = normalizedUsername; user.pendingAction = null; addBadge(user, 'linked_profile'); if (!wasLinked) user.profileXP += 10; trackOnboardingProgress(user); await reactToMessage(ctx, 'πŸ”—'); + + // If triggered mid-flow (e.g. bonus request), resume that flow after linking + if (returnTo === 'w30_bonus') { + await ctx.reply(`βœ… Username linked: *${normalizedUsername}*\n\nContinuing your bonus request…`, { parse_mode: 'Markdown' }); + const check = checkBonusEligibility(user); + if (check.ok) { + await submitBonusRequest(ctx, user); + } else if (check.needsWager) { + user.pendingAction = { type: 'w30_await_wager_total' }; + await ctx.reply( + 'What is your total SC wagered in the past 7 days?\n\n' + + 'Enter the number only (e.g. 3500). No screenshots needed β€” admin verifies this on Runewager.', + ); + } else { + await ctx.reply(check.reason); + } + return; + } + const nextStep = getNextOnboardingStep(user); const nextStepText = nextStep < 5 ? `\n\n➑️ Next step: ${onboardingStepLabel(nextStep)}` : '\n\nπŸŽ‰ Onboarding complete! You can now request the 30 SC bonus.'; await ctx.reply( @@ -3694,6 +3881,14 @@ async function startBot() { await configureBotSurface(); await bot.launch(); logEvent('info', 'Runewager Telegram Bot is running', { port: Number(process.env.PORT || 3000) }); + + // Notify all admins that the bot is online β€” confirms deploys/restarts landed. + // Sent silently (no sound/vibration) so it never wakes anyone up mid-night. + const startedAt = new Date().toLocaleString('en-GB', { hour12: false, timeZone: 'UTC' }); + const onlineMsg = `🟒 *Runewager Bot Online*\n\`${startedAt} UTC\`\nPID: ${process.pid} | Node: ${process.version}`; + for (const adminId of ADMIN_IDS) { + bot.telegram.sendMessage(adminId, onlineMsg, { parse_mode: 'Markdown', disable_notification: true }).catch(() => {}); + } } catch (e) { logEvent('error', 'Failed to launch bot', { error: e.message }); process.exit(1); diff --git a/prod-run.sh b/prod-run.sh index 3f8f093..2a6586f 100755 --- a/prod-run.sh +++ b/prod-run.sh @@ -10,8 +10,16 @@ set -euo pipefail # --------------------------------------------------------- # 0) Resolve project directory & cd into it +# Primary: directory the script lives in (works wherever prod-run.sh is called from) +# Fallback: /var/www/html/Runewager (standard VPS deploy path) # --------------------------------------------------------- PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ ! -f "$PROJECT_DIR/index.js" ]]; then + VPS_DEPLOY_PATH="/var/www/html/Runewager" + if [[ -f "$VPS_DEPLOY_PATH/index.js" ]]; then + PROJECT_DIR="$VPS_DEPLOY_PATH" + fi +fi cd "$PROJECT_DIR" APP_NAME="runewager" @@ -169,9 +177,14 @@ UNIT if [[ -w /etc/systemd/system ]] || [[ -f "$SERVICE_FILE" && -w "$SERVICE_FILE" ]]; then printf '%s\n' "$conf" > "$SERVICE_FILE" - systemctl daemon-reload - systemctl enable "${APP_NAME}.service" 2>/dev/null || true - say "Systemd service installed and enabled: $SERVICE_FILE" + # Guard: systemctl may not exist on non-systemd systems + if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload + systemctl enable "${APP_NAME}.service" 2>/dev/null || true + say "Systemd service installed and enabled: $SERVICE_FILE" + else + warn "systemctl not found β€” service file written but not loaded (non-systemd host)" + fi else warn "No write access to /etc/systemd/system β€” re-run as root to install service" fi @@ -217,12 +230,23 @@ LRCONF return fi - # Validate config β€” capture output, only flag actual errors (suppress debug spam) + # Validate config β€” only if logrotate binary is available + if ! command -v logrotate >/dev/null 2>&1; then + warn "logrotate not found β€” skipping validation (config written)" + return + fi + + # Capture dry-run output; only flag actual errors (suppress verbose debug spam) local lr_out lr_out="$(logrotate -d -s /dev/null "$LOGROTATE_FILE" 2>&1)" || true if printf '%s\n' "$lr_out" | grep -qi "error"; then warn "Logrotate validation found errors β€” adding cron fallback" + # Guard: crontab may not exist on minimal systems + if ! command -v crontab >/dev/null 2>&1; then + warn "crontab not found β€” cannot add fallback cron job" + return + fi local cron_line="0 3 * * * /usr/sbin/logrotate -f $LOGROTATE_FILE # ${APP_NAME}_logrotate" local current_cron current_cron="$(crontab -l 2>/dev/null || true)" @@ -282,25 +306,23 @@ else fi # --------------------------------------------------------- -# 11) Start bot if not running +# 11) Safe restart β€” always restart to pick up any pulled code changes +# Uses systemctl restart (graceful SIGTERM β†’ wait β†’ start) when available, +# otherwise kills the old PID and relaunches via nohup. # --------------------------------------------------------- -if [[ -z "$PID" ]]; then - say "Bot not running β€” starting via systemd" +if [[ -n "$PID" ]]; then + say "Bot running (PID: $PID) β€” performing safe restart to apply pulled changes" +else + say "Bot not running β€” starting fresh" +fi - if command -v systemctl >/dev/null 2>&1 && [[ -f "$SERVICE_FILE" ]]; then - if systemctl restart "${APP_NAME}.service" 2>&1; then - sleep 3 - PID="$(get_bot_pid)" - else - warn "systemctl restart failed β€” falling back to nohup launch" - nohup node "$PROJECT_DIR/index.js" \ - >> "$MAIN_LOG" 2>> "$ERROR_LOG" < /dev/null & - disown || true - sleep 3 - PID="$(get_bot_pid)" - fi +if command -v systemctl >/dev/null 2>&1 && [[ -f "$SERVICE_FILE" ]]; then + if systemctl restart "${APP_NAME}.service" 2>&1; then + sleep 3 + PID="$(get_bot_pid)" else - say "systemctl unavailable or service file missing β€” launching with nohup" + warn "systemctl restart failed β€” falling back to kill+nohup" + [[ -n "$PID" ]] && { kill "$PID" 2>/dev/null || true; sleep 2; } nohup node "$PROJECT_DIR/index.js" \ >> "$MAIN_LOG" 2>> "$ERROR_LOG" < /dev/null & disown || true @@ -308,7 +330,13 @@ if [[ -z "$PID" ]]; then PID="$(get_bot_pid)" fi else - say "Bot already running β€” skipping launch" + say "systemctl unavailable β€” using kill+nohup restart" + [[ -n "$PID" ]] && { kill "$PID" 2>/dev/null || true; sleep 2; } + nohup node "$PROJECT_DIR/index.js" \ + >> "$MAIN_LOG" 2>> "$ERROR_LOG" < /dev/null & + disown || true + sleep 3 + PID="$(get_bot_pid)" fi # ---------------------------------------------------------