From a74ae80a9f7e6e304ea2d10fda073c05e034a53c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 00:36:41 +0000 Subject: [PATCH 1/7] feat: add rotating group tips system with admin controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tipsStore with 15 default sweepstakes-safe tips (id, text, enabled) - Add TIPS_GROUP constant (@GambleCodezPrizeHub, overridable via env) - Persist tipsStore in runtime-state.json (snapshot + load) - Start tips scheduler on bot launch: posts one random enabled tip silently every 4 hours to @GambleCodezPrizeHub (disable_notification) - Scheduler is re-armable when admin changes the interval Admin commands: /tips /t /tp — Tips Manager dashboard with inline buttons /tiplist — Show all tips with IDs and preview /tipadd — Prompt for new tip text (state: await_tip_add_text) /tipremove — Select tip by button to delete /tipedit — Select tip by button then prompt for new text /tiptoggle — Toggle entire tips system on/off /tiptest — Send one random tip preview to admin in DM /tipsettings — Show settings and update interval (hours) Inline button actions: tips_cmd_{add,edit,remove,toggle,list,test,settings} tip_remove_ — remove a specific tip tip_edit_select_ — prompt to edit a specific tip tip_toggle_ — enable/disable individual tip pendingAction state machine: await_tip_add_text → save new tip, reply "Added as Tip #X" await_tip_edit_text → update tip text, reply "Tip #X updated" await_tip_settings_interval → update interval + restart scheduler https://claude.ai/code/session_01X3PxGFF5zzKptQwVkjYzzN --- index.js | 349 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) diff --git a/index.js b/index.js index 25f8a6c..401a94a 100644 --- a/index.js +++ b/index.js @@ -47,6 +47,9 @@ const DEFAULT_BONUS_RULE = `Wager 3,000 SC in any rolling 7-day period → 30 SC // Channel username (or chat_id) for admin announcements via /announce const ANNOUNCE_CHANNEL = process.env.ANNOUNCE_CHANNEL || '@GambleCodezDrops'; +// Group username (or chat_id) for automatic silent tips +const TIPS_GROUP = process.env.TIPS_GROUP || '@GambleCodezPrizeHub'; + // ── Images ───────────────────────────────────────────────────────────────── // Images live in /images/ next to index.js. // If HOSTED_*_URL env vars are set they take priority (CDN / public URL). @@ -140,6 +143,37 @@ const giveawayStore = { counter: 1, }; +// ========================= +// Tips Store — rotating silent group posts +// ========================= +const DEFAULT_TIPS_LIST = [ + { id: 1, text: '💡 *Did you know?* Runewager gives *instant crypto redemptions* on all sweepstakes prizes. No delays, no stress — just claim and flex.', enabled: true }, + { id: 2, text: '👋 New here? Tap /start in the bot to unlock your *FREE SC bonuses*, link your account, and join our giveaways.', enabled: true }, + { id: 3, text: '🏆 Runewager\'s weekly leaderboard is *LIVE* — climb the ranks, earn bragging rights, and win real prizes. All 100% free to play.', enabled: true }, + { id: 4, text: '🔗 *Pro tip:* Linking your Runewager username in the bot unlocks bonuses, giveaways, and faster support.', enabled: true }, + { id: 5, text: '🎁 Want more SC? Join our Telegram channel for surprise drops, flash bonuses, and exclusive codes.', enabled: true }, + { id: 6, text: '🎮 Runewager features top providers like *Hacksaw*, *BGaming*, *Betsoft*, and more — all sweepstakes‑safe and free to play.', enabled: true }, + { id: 7, text: '🔒 Make sure you\'re Discord‑verified in the bot — it unlocks extra rewards and keeps your account secure.', enabled: true }, + { id: 8, text: '🎉 Tap *Giveaways* in the bot menu to join our SC giveaways with automatic eligibility checks.', enabled: true }, + { id: 9, text: '🌍 Runewager accepts players worldwide — no region blocks, no deposit requirements, just pure sweepstakes fun.', enabled: true }, + { id: 10, text: '💰 Your bonuses refresh often — check the bot for new user bonuses, 30 SC rewards, and seasonal promos.', enabled: true }, + { id: 11, text: '🤝 Want to help the community grow? Share the bot link with a friend — more players means bigger giveaways.', enabled: true }, + { id: 12, text: '❓ If you ever get stuck, tap *Help / Commands* in the bot for a full guide, tooltips, and troubleshooting.', enabled: true }, + { id: 13, text: '📱 Runewager\'s mini‑app loads instantly inside Telegram — or switch to browser mode in *Settings* if you prefer.', enabled: true }, + { id: 14, text: '👀 Stay active in the group — admins drop surprise SC codes and leaderboard boosts at random times.', enabled: true }, + { id: 15, text: '🆓 Reminder: Runewager is *100% free to play*. No deposits, no wagering, no risk — just sweepstakes fun and instant crypto prizes.', enabled: true }, +]; + +const tipsStore = { + tips: DEFAULT_TIPS_LIST.map((t) => ({ ...t })), + systemEnabled: true, + intervalHours: 4, + targetGroup: TIPS_GROUP, + nextTipId: 16, +}; + +let tipsTimerRef = null; + const dataDir = path.join(__dirname, 'data'); const promoHistoryFile = path.join(dataDir, 'promo-history.json'); const analyticsFile = path.join(dataDir, 'analytics.json'); @@ -287,6 +321,13 @@ function createRuntimeStateSnapshot() { running: Array.from(giveawayStore.running.entries()).map(([id, g]) => ([id, serializeGiveaway(g)])), history: Array.from(giveawayStore.history.entries()).map(([id, g]) => ([id, serializeGiveaway(g)])), }, + tipsStore: { + tips: tipsStore.tips, + systemEnabled: tipsStore.systemEnabled, + intervalHours: tipsStore.intervalHours, + targetGroup: tipsStore.targetGroup, + nextTipId: tipsStore.nextTipId, + }, }; } @@ -383,6 +424,20 @@ function loadRuntimeState() { } } } + + if (raw.tipsStore) { + if (Array.isArray(raw.tipsStore.tips) && raw.tipsStore.tips.length > 0) { + tipsStore.tips = raw.tipsStore.tips; + } + if (typeof raw.tipsStore.systemEnabled === 'boolean') tipsStore.systemEnabled = raw.tipsStore.systemEnabled; + if (typeof raw.tipsStore.intervalHours === 'number' && raw.tipsStore.intervalHours > 0) { + tipsStore.intervalHours = raw.tipsStore.intervalHours; + } + if (typeof raw.tipsStore.targetGroup === 'string' && raw.tipsStore.targetGroup) { + tipsStore.targetGroup = raw.tipsStore.targetGroup; + } + if (typeof raw.tipsStore.nextTipId === 'number') tipsStore.nextTipId = raw.tipsStore.nextTipId; + } } function persistRuntimeState() { @@ -5119,6 +5174,59 @@ bot.on('text', async (ctx) => { return; } + // Tips system: await new tip text + if (action.type === 'await_tip_add_text') { + if (!requireAdmin(ctx)) return; + if (!text) { + await ctx.reply('Tip text cannot be empty. Please send the tip you want to add.'); + return; + } + const newTip = { id: tipsStore.nextTipId, text, enabled: true }; + tipsStore.tips.push(newTip); + tipsStore.nextTipId += 1; + user.pendingAction = null; + persistRuntimeState(); + await ctx.reply(`Added as Tip #${newTip.id}.`); + return; + } + + // Tips system: await edited tip text + if (action.type === 'await_tip_edit_text') { + if (!requireAdmin(ctx)) return; + if (!text) { + await ctx.reply('Tip text cannot be empty. Please send the updated tip text.'); + return; + } + const tipId = action.data && action.data.tipId; + const tip = tipsStore.tips.find((t) => t.id === tipId); + if (!tip) { + user.pendingAction = null; + await ctx.reply('Tip not found — it may have been removed.'); + return; + } + tip.text = text; + user.pendingAction = null; + persistRuntimeState(); + await ctx.reply(`Tip #${tipId} updated.`); + return; + } + + // Tips system: await new interval (hours) + if (action.type === 'await_tip_settings_interval') { + if (!requireAdmin(ctx)) return; + const hours = Number(text); + if (!Number.isFinite(hours) || hours <= 0) { + await ctx.reply('Please send a valid number of hours (e.g. `4`).', { parse_mode: 'Markdown' }); + return; + } + tipsStore.intervalHours = hours; + user.pendingAction = null; + persistRuntimeState(); + startTipsScheduler(); // re-arm with new interval + await ctx.reply(`✅ Tip interval updated to every ${hours} hour(s). Scheduler restarted.`); + return; + } + // Admin announce: await announcement text if (action.type === 'await_announcement_text') { if (!requireAdmin(ctx)) return; @@ -5289,6 +5397,243 @@ bot.action('announce_send_channel', async (ctx) => { } }); +// ========================= +// Tips System — scheduler + admin commands + callbacks +// ========================= + +/** + * Start (or restart) the tips scheduler. + * Clears any existing timer then arms a fresh one using the current interval. + */ +function startTipsScheduler() { + if (tipsTimerRef) clearInterval(tipsTimerRef); + const ms = (tipsStore.intervalHours || 4) * 60 * 60 * 1000; + tipsTimerRef = setInterval(async () => { + if (!tipsStore.systemEnabled) return; + const enabled = tipsStore.tips.filter((t) => t.enabled); + if (!enabled.length) return; + const tip = enabled[Math.floor(Math.random() * enabled.length)]; + try { + await bot.telegram.sendMessage(tipsStore.targetGroup, tip.text, { + parse_mode: 'Markdown', + disable_notification: true, + }); + logEvent('info', 'Group tip posted', { tipId: tip.id }); + } catch (e) { + logEvent('warn', 'Failed to post group tip', { tipId: tip.id, error: e.message }); + } + }, ms); +} + +/** Build the Tips Manager dashboard keyboard */ +function tipsDashboardKeyboard() { + return Markup.inlineKeyboard([ + [Markup.button.callback('➕ Add Tip', 'tips_cmd_add'), Markup.button.callback('✏️ Edit Tip', 'tips_cmd_edit')], + [Markup.button.callback('❌ Remove Tip', 'tips_cmd_remove'), Markup.button.callback('🔁 Toggle System', 'tips_cmd_toggle')], + [Markup.button.callback('📋 View All Tips', 'tips_cmd_list'), Markup.button.callback('🧪 Test Random Tip', 'tips_cmd_test')], + [Markup.button.callback('⚙️ Settings', 'tips_cmd_settings')], + [Markup.button.callback('↩ Admin Menu', 'pamenu_back_admin')], + ]); +} + +/** Build a keyboard with one button per tip (for select-to-edit or select-to-remove) */ +function tipSelectKeyboard(action) { + const rows = []; + let row = []; + for (const tip of tipsStore.tips) { + row.push(Markup.button.callback(`#${tip.id}${tip.enabled ? '' : ' 🔇'}`, `${action}_${tip.id}`)); + if (row.length === 5) { rows.push(row); row = []; } + } + if (row.length) rows.push(row); + rows.push([Markup.button.callback('❌ Cancel', 'tips_select_cancel')]); + return Markup.inlineKeyboard(rows); +} + +/** Send (or re-send) the Tips Manager dashboard */ +async function sendTipsDashboard(ctx) { + const total = tipsStore.tips.length; + const enabled = tipsStore.tips.filter((t) => t.enabled).length; + const status = tipsStore.systemEnabled ? '🟢 Enabled' : '🔴 Disabled'; + const text = `📝 *Tips Manager*\n\nStatus: ${status}\nTotal Tips: ${total} (${enabled} active)\nInterval: every ${tipsStore.intervalHours}h\nTarget: ${tipsStore.targetGroup}`; + await ctx.reply(text, { parse_mode: 'Markdown', ...tipsDashboardKeyboard() }); +} + +// ── /tips /tiplist /tipadd /tipremove /tipedit /tiptoggle /tiptest /tipsettings ── + +async function handleTipsCommand(ctx) { + if (!requireAdmin(ctx)) return; + await sendTipsDashboard(ctx); +} + +bot.command('tips', handleTipsCommand); +bot.command('t', handleTipsCommand); +bot.command('tp', handleTipsCommand); + +bot.command('tiplist', async (ctx) => { + if (!requireAdmin(ctx)) return; + const lines = ['📋 *Current Tips:*\n']; + for (const tip of tipsStore.tips) { + const preview = tip.text.replace(/\*/g, '').slice(0, 80); + lines.push(`${tip.id}. ${tip.enabled ? '' : '🔇 '}${preview}${tip.text.length > 80 ? '…' : ''}`); + } + await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown' }); +}); + +bot.command('tipadd', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + clearPendingAction(user); + user.pendingAction = { type: 'await_tip_add_text' }; + await ctx.reply('Send the new tip text. HTML and Markdown are both allowed.'); +}); + +bot.command('tipremove', async (ctx) => { + if (!requireAdmin(ctx)) return; + if (!tipsStore.tips.length) { await ctx.reply('No tips to remove.'); return; } + await ctx.reply('Remove which tip?', tipSelectKeyboard('tip_remove')); +}); + +bot.command('tipedit', async (ctx) => { + if (!requireAdmin(ctx)) return; + if (!tipsStore.tips.length) { await ctx.reply('No tips to edit.'); return; } + await ctx.reply('Edit which tip?', tipSelectKeyboard('tip_edit_select')); +}); + +bot.command('tiptoggle', async (ctx) => { + if (!requireAdmin(ctx)) return; + tipsStore.systemEnabled = !tipsStore.systemEnabled; + persistRuntimeState(); + const status = tipsStore.systemEnabled ? '🟢 Tips System Enabled' : '🔴 Tips System Disabled'; + await ctx.reply(status); +}); + +bot.command('tiptest', async (ctx) => { + if (!requireAdmin(ctx)) return; + const enabled = tipsStore.tips.filter((t) => t.enabled); + if (!enabled.length) { await ctx.reply('No enabled tips to preview.'); return; } + const tip = enabled[Math.floor(Math.random() * enabled.length)]; + await ctx.reply(`Here is a random tip preview:\n\n${tip.text}`, { parse_mode: 'Markdown' }); +}); + +bot.command('tipsettings', async (ctx) => { + if (!requireAdmin(ctx)) return; + const text = `⚙️ *Tips Settings*\n\nInterval: every ${tipsStore.intervalHours} hours\nSilent mode: Always ON\nTarget group: ${tipsStore.targetGroup}\n\nTo change the interval, reply with the number of hours (e.g. \`4\`).`; + const user = getUser(ctx); + clearPendingAction(user); + user.pendingAction = { type: 'await_tip_settings_interval' }; + await ctx.reply(text, { parse_mode: 'Markdown' }); +}); + +// ── Tips Dashboard inline button actions ── + +bot.action('tips_cmd_add', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + await ctx.answerCbQuery(); + clearPendingAction(user); + user.pendingAction = { type: 'await_tip_add_text' }; + await ctx.reply('Send the new tip text. HTML and Markdown are both allowed.'); +}); + +bot.action('tips_cmd_edit', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + if (!tipsStore.tips.length) { await ctx.reply('No tips to edit.'); return; } + await ctx.reply('Edit which tip?', tipSelectKeyboard('tip_edit_select')); +}); + +bot.action('tips_cmd_remove', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + if (!tipsStore.tips.length) { await ctx.reply('No tips to remove.'); return; } + await ctx.reply('Remove which tip?', tipSelectKeyboard('tip_remove')); +}); + +bot.action('tips_cmd_toggle', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + tipsStore.systemEnabled = !tipsStore.systemEnabled; + persistRuntimeState(); + const status = tipsStore.systemEnabled ? '🟢 Tips System Enabled' : '🔴 Tips System Disabled'; + await ctx.reply(status); + await sendTipsDashboard(ctx); +}); + +bot.action('tips_cmd_list', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + const lines = ['📋 *Current Tips:*\n']; + for (const tip of tipsStore.tips) { + const preview = tip.text.replace(/\*/g, '').slice(0, 80); + lines.push(`${tip.id}. ${tip.enabled ? '' : '🔇 '}${preview}${tip.text.length > 80 ? '…' : ''}`); + } + await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown' }); +}); + +bot.action('tips_cmd_test', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + const enabled = tipsStore.tips.filter((t) => t.enabled); + if (!enabled.length) { await ctx.reply('No enabled tips to preview.'); return; } + const tip = enabled[Math.floor(Math.random() * enabled.length)]; + await ctx.reply(`Here is a random tip preview:\n\n${tip.text}`, { parse_mode: 'Markdown' }); +}); + +bot.action('tips_cmd_settings', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + const user = getUser(ctx); + clearPendingAction(user); + user.pendingAction = { type: 'await_tip_settings_interval' }; + const text = `⚙️ *Tips Settings*\n\nInterval: every ${tipsStore.intervalHours} hours\nSilent mode: Always ON\nTarget group: ${tipsStore.targetGroup}\n\nSend the new interval in hours (e.g. \`4\`).`; + await ctx.reply(text, { parse_mode: 'Markdown' }); +}); + +bot.action('tips_select_cancel', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + await ctx.answerCbQuery('Cancelled'); + clearPendingAction(user); + await ctx.reply('Cancelled.'); +}); + +// tip_remove_ +bot.action(/^tip_remove_(\d+)$/, async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + const tipId = Number(ctx.match[1]); + const idx = tipsStore.tips.findIndex((t) => t.id === tipId); + if (idx === -1) { await ctx.reply('Tip not found.'); return; } + tipsStore.tips.splice(idx, 1); + persistRuntimeState(); + await ctx.reply(`Tip #${tipId} removed.`); +}); + +// tip_edit_select_ +bot.action(/^tip_edit_select_(\d+)$/, async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + const tipId = Number(ctx.match[1]); + const tip = tipsStore.tips.find((t) => t.id === tipId); + if (!tip) { await ctx.reply('Tip not found.'); return; } + const user = getUser(ctx); + clearPendingAction(user); + user.pendingAction = { type: 'await_tip_edit_text', data: { tipId } }; + await ctx.reply(`Send the updated text for Tip #${tipId}.\n\nCurrent text:\n${tip.text}`); +}); + +// tip_toggle_ (per-tip enable/disable, accessible from tiplist) +bot.action(/^tip_toggle_(\d+)$/, async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + const tipId = Number(ctx.match[1]); + const tip = tipsStore.tips.find((t) => t.id === tipId); + if (!tip) { await ctx.reply('Tip not found.'); return; } + tip.enabled = !tip.enabled; + persistRuntimeState(); + await ctx.reply(`Tip #${tipId} is now ${tip.enabled ? '✅ enabled' : '🔇 disabled'}.`); +}); + // ========================= // Giveaway Wizard action handlers // ========================= @@ -6645,6 +6990,10 @@ async function startBot() { for (const adminId of ADMIN_IDS) { bot.telegram.sendMessage(adminId, onlineMsg, { parse_mode: 'Markdown', disable_notification: true }).catch(() => {}); } + + // Start the rotating group tips scheduler + startTipsScheduler(); + logEvent('info', 'Tips scheduler started', { intervalHours: tipsStore.intervalHours, target: tipsStore.targetGroup }); } catch (e) { logEvent('error', 'Failed to launch bot', { error: e.message }); process.exit(1); From 9d23185f52cee19ce07d0275e443261b7859c629 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 00:42:30 +0000 Subject: [PATCH 2/7] feat: disable GitHub hosting, add VPS-only runtime guard, add deploy.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub CI: - deploy.yml: disable `deploy` job with `if: false` — GitHub Actions must NEVER restart or launch the bot; it is code storage only. Quality-gates job (syntax, tests, audit) continues to run on push. - ci.yml: unchanged — already runs tests without touching Telegram. index.js runtime guards (layered): - CI / smoke-test layer: if CI=true or DISABLE_RUNTIME=1 → log and skip all runtime startup without calling process.exit() so that `require('./index.js')` in smoke tests completes cleanly. - VPS-only layer: if DEVICE !== "vps" → print warning and exit(0). Set DEVICE=vps in the VPS .env to allow the bot to start. /deploy admin command: - Restart logic is systemctl-only (no PM2); unchanged from original. deploy.sh (new, VPS-only): - git fetch --all && git reset --hard origin/main - npm ci --omit=dev - systemctl restart runewager - systemctl is-active confirmation .env.example: - Added DEVICE=vps entry so prod-run.sh copies it correctly. https://claude.ai/code/session_01X3PxGFF5zzKptQwVkjYzzN --- .env.example | 1 + .github/workflows/deploy.yml | 9 ++-- deploy.sh | 70 ++++++++++++++++++++++++++ index.js | 98 +++++++++++++++++++++--------------- 4 files changed, 135 insertions(+), 43 deletions(-) create mode 100755 deploy.sh diff --git a/.env.example b/.env.example index 0a78d24..090d759 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ AFFILIATE_SOURCE=GambleCodez +DEVICE=vps ADMIN_IDS=6668510825 BOT_TOKEN= DISCORD_CODE_GENERATION_IMAGE_URL=https://github.com/gamblecodezcom/Runewager/blob/38c1bbab2d954238829852981514ce92c49d8b10/images/discord_code_generation.png diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 409c0c7..3325f99 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -151,13 +151,16 @@ Mode: \`${DEPLOY_MODE}\` By: \`${{ github.actor }}\`" # --------------------------------------------------------------------------- - # Job 2: Deploy to VPS (skipped on dry-run) + # Job 2: Deploy to VPS — DISABLED + # Deployments are performed directly on the VPS by running deploy.sh. + # GitHub Actions must NEVER launch, restart, or host the bot. + # To deploy: SSH into the VPS and run: bash /var/www/html/Runewager/deploy.sh # --------------------------------------------------------------------------- deploy: - name: Deploy to VPS + name: Deploy to VPS (DISABLED — run deploy.sh on VPS directly) runs-on: ubuntu-latest needs: quality-gates - if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' }} + if: false # GitHub must never trigger VPS restarts or bot launches environment: name: production diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..4b54587 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# ============================================================= +# Runewager — VPS Deployment Script +# +# Run this directly on the VPS to deploy the latest code. +# GitHub Actions does NOT run this script — it is VPS-only. +# +# Usage: +# bash /var/www/html/Runewager/deploy.sh +# +# What it does: +# 1. git fetch + reset to origin/main (clean, no merge conflicts) +# 2. npm ci --omit=dev (install/update production deps) +# 3. systemctl restart runewager +# 4. Confirm service status +# ============================================================= +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_NAME="runewager" + +say() { printf '[%s] %s\n' "$APP_NAME" "$*"; } +warn() { printf '[%s][warn] %s\n' "$APP_NAME" "$*" >&2; } + +say "=== Runewager VPS Deployment ===" +say "Project: $PROJECT_DIR" +say "Date: $(date -u '+%Y-%m-%dT%H:%M:%SZ')" + +cd "$PROJECT_DIR" + +# --------------------------------------------------------- +# 1) Pull latest code from origin/main +# --------------------------------------------------------- +say "Step 1/4 — Fetching latest code from origin/main…" +git fetch --all +git reset --hard origin/main +say "Now at: $(git rev-parse --short HEAD)" + +# --------------------------------------------------------- +# 2) Install / update production dependencies +# --------------------------------------------------------- +say "Step 2/4 — Installing production dependencies…" +if [[ -f package-lock.json ]]; then + npm ci --omit=dev +else + npm install --omit=dev +fi +say "Dependencies installed." + +# --------------------------------------------------------- +# 3) Restart via systemctl +# --------------------------------------------------------- +say "Step 3/4 — Restarting bot via systemctl…" +if command -v systemctl >/dev/null 2>&1; then + systemctl restart "${APP_NAME}.service" + say "Bot restarted." +else + warn "systemctl not found. Restart manually: node $PROJECT_DIR/index.js" +fi + +# --------------------------------------------------------- +# 4) Confirm service status +# --------------------------------------------------------- +say "Step 4/4 — Confirming bot status…" +sleep 3 +ACTIVE="$(systemctl is-active "${APP_NAME}.service" 2>/dev/null || echo unknown)" +say "Service status: $ACTIVE" + +say "" +say "=== Deployment complete. ===" diff --git a/index.js b/index.js index 401a94a..3667184 100644 --- a/index.js +++ b/index.js @@ -7000,48 +7000,66 @@ async function startBot() { } } -const healthServer = startHealthServer(); -startBot(); - -const WEEK_MS = 7 * 24 * 60 * 60 * 1000; -setInterval(() => { - runWeeklyWagerReminder().catch(() => { - // ignore reminder errors to keep bot alive - }); -}, WEEK_MS); +// ========================= +// Runtime environment guard +// +// GitHub CI → CI=true or DISABLE_RUNTIME=1 → log and skip (no process.exit so +// smoke tests can require() this file and verify it loads cleanly). +// Non-VPS → DEVICE !== "vps" → exit(0) immediately. +// Set DEVICE=vps in the VPS .env to allow the bot to start. +// +// All bot handler registrations above this block are pure in-memory operations +// and are safe to run in any environment. +// ========================= +if (process.env.CI === 'true' || process.env.DISABLE_RUNTIME === '1') { + console.log('CI mode detected — bot runtime disabled.'); +} else if (process.env.DEVICE !== 'vps') { + console.log('Bot runtime disabled — not running on VPS. Set DEVICE=vps in .env to enable.'); + process.exit(0); +} else { + const healthServer = startHealthServer(); + startBot(); + + const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + setInterval(() => { + runWeeklyWagerReminder().catch(() => { + // ignore reminder errors to keep bot alive + }); + }, WEEK_MS); -setInterval(() => { - persistAnalytics(); - persistRuntimeState(); - buildDashboard(); -}, 60 * 1000); + setInterval(() => { + persistAnalytics(); + persistRuntimeState(); + buildDashboard(); + }, 60 * 1000); -setInterval(() => { - referralStore.weekly.clear(); -}, WEEK_MS); + setInterval(() => { + referralStore.weekly.clear(); + }, WEEK_MS); -setInterval(() => { - try { - if (fs.existsSync(runtimeStateFile)) { - const backupPath = createRuntimeBackup(); - logEvent('info', 'Runtime backup created', { backupPath }); + setInterval(() => { + try { + if (fs.existsSync(runtimeStateFile)) { + const backupPath = createRuntimeBackup(); + logEvent('info', 'Runtime backup created', { backupPath }); + } + } catch (e) { + logEvent('error', 'Runtime backup failed', { error: e.message }); } - } catch (e) { - logEvent('error', 'Runtime backup failed', { error: e.message }); - } -}, 24 * 60 * 60 * 1000); + }, 24 * 60 * 60 * 1000); -process.once('SIGINT', () => { - persistAnalytics(); - persistRuntimeState(); - buildDashboard(); - healthServer.close(); - bot.stop('SIGINT'); -}); -process.once('SIGTERM', () => { - persistAnalytics(); - persistRuntimeState(); - buildDashboard(); - healthServer.close(); - bot.stop('SIGTERM'); -}); + process.once('SIGINT', () => { + persistAnalytics(); + persistRuntimeState(); + buildDashboard(); + healthServer.close(); + bot.stop('SIGINT'); + }); + process.once('SIGTERM', () => { + persistAnalytics(); + persistRuntimeState(); + buildDashboard(); + healthServer.close(); + bot.stop('SIGTERM'); + }); +} From b7ec945216ad000418616536e3fa13b2a0501336 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 01:12:39 +0000 Subject: [PATCH 3/7] fix: replace git pull with fetch+reset --hard in /deploy command and scripts - /deploy Telegram command: git pull -> git fetch --all + git reset --hard origin/main + git clean -fd. Prevents hangs caused by dirty working trees, untracked files, or merge conflicts that made /deploy stuck. - prod-run.sh: same fetch+reset change for the initial code-pull step. - deploy.sh: add systemctl stop before git ops (prevents file locks during reset) and git clean -fd after reset (removes stale untracked files). All three paths now use the same hard-reset strategy. Systemd service, CI guard (CI=true/DISABLE_RUNTIME=1), and DEVICE=vps guard are unchanged as they were already correct. https://claude.ai/code/session_01X3PxGFF5zzKptQwVkjYzzN --- deploy.sh | 11 ++++++++++- index.js | 14 ++++++++++---- prod-run.sh | 12 +++++++----- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/deploy.sh b/deploy.sh index 4b54587..3478e54 100755 --- a/deploy.sh +++ b/deploy.sh @@ -29,11 +29,20 @@ say "Date: $(date -u '+%Y-%m-%dT%H:%M:%SZ')" cd "$PROJECT_DIR" # --------------------------------------------------------- -# 1) Pull latest code from origin/main +# 0) Stop service before touching files (prevents file locks) +# --------------------------------------------------------- +if command -v systemctl >/dev/null 2>&1; then + say "Step 0/4 — Stopping ${APP_NAME} service…" + systemctl stop "${APP_NAME}.service" || true +fi + +# --------------------------------------------------------- +# 1) Pull latest code from origin/main (hard reset — no merge conflicts) # --------------------------------------------------------- say "Step 1/4 — Fetching latest code from origin/main…" git fetch --all git reset --hard origin/main +git clean -fd say "Now at: $(git rev-parse --short HEAD)" # --------------------------------------------------------- diff --git a/index.js b/index.js index 3667184..d70b14f 100644 --- a/index.js +++ b/index.js @@ -2526,11 +2526,17 @@ bot.command('deploy', async (ctx) => { .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); + // ── Step 1: fetch + hard reset (avoids hang on dirty tree / merge conflicts) ── + const fetchResult = await runCmd('git', ['fetch', '--all'], PROJECT_DIR, 30000); + const gitResult = fetchResult.ok + ? await runCmd('git', ['reset', '--hard', 'origin/main'], PROJECT_DIR, 15000) + : fetchResult; + if (gitResult.ok) { + await runCmd('git', ['clean', '-fd'], PROJECT_DIR, 10000).catch(() => {}); + } const gitLine = gitResult.ok - ? `✅ *git pull:* \`${sanitizeCmdOutput(gitResult.out.split('\n')[0])}\`` - : `⚠️ *git pull failed:* \`${sanitizeCmdOutput(gitResult.err)}\``; + ? `✅ *git update:* \`${sanitizeCmdOutput(gitResult.out.split('\n')[0])}\`` + : `⚠️ *git update failed:* \`${sanitizeCmdOutput(gitResult.err || fetchResult.err)}\``; await edit( `🚀 *Deployment in progress* — ${ts()}\n\n` diff --git a/prod-run.sh b/prod-run.sh index 2a6586f..6f29904 100755 --- a/prod-run.sh +++ b/prod-run.sh @@ -38,13 +38,15 @@ say "Project directory: $PROJECT_DIR" say "Running as: $(id -un) (uid=$(id -u) gid=$(id -g))" # --------------------------------------------------------- -# 1) Pull latest code from origin main [FIRST — before anything] +# 1) Fetch + hard reset to origin/main [FIRST — before anything] +# Using fetch+reset instead of pull avoids hangs on dirty trees / conflicts. # --------------------------------------------------------- -say "Pulling latest code from origin main..." -if git -C "$PROJECT_DIR" pull origin main 2>&1; then - say "Git pull successful" +say "Fetching latest code from origin main..." +if git -C "$PROJECT_DIR" fetch --all 2>&1 && git -C "$PROJECT_DIR" reset --hard origin/main 2>&1; then + git -C "$PROJECT_DIR" clean -fd 2>&1 || true + say "Git fetch + reset successful: $(git -C "$PROJECT_DIR" rev-parse --short HEAD)" else - warn "Git pull failed — continuing with local copy" + warn "Git fetch/reset failed — continuing with local copy" fi # --------------------------------------------------------- From dfab1f49d8af9a87ab278fec0e8bf790de14ca31 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 01:22:34 +0000 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20deploy=20only=20on=20successful=20PR?= =?UTF-8?q?=20merges=20=E2=80=94=20prevent=20revert/accidental=20deploys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy.yml: - Change trigger from push:main to pull_request:types:[closed] - Add merge guard to quality-gates job: only runs when merged==true, base.ref==main, and merge_commit_sha is present. workflow_dispatch still works for manual deploys. Revert PRs, closed-without-merge, draft PRs, and direct pushes are all completely ignored. - Enable deploy job (was if:false) — now fires only when quality gates pass. Deploy step simplified to a single SSH call: bash deploy.sh deploy.sh: - Add up-to-date hash check (git ls-remote vs local HEAD) before systemctl stop. If VPS already has the latest commit, exit 0 immediately without stopping the service or touching anything. index.js (/deploy command): - After git fetch, compare local HEAD vs origin/main. If equal, reply "Bot is already running the latest version." and return early — no reset, no npm ci, no restart. https://claude.ai/code/session_01X3PxGFF5zzKptQwVkjYzzN --- .github/workflows/deploy.yml | 54 ++++++++++++++---------------------- deploy.sh | 14 +++++++++- index.js | 15 ++++++++++ 3 files changed, 49 insertions(+), 34 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3325f99..ddc166e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,8 +1,8 @@ name: Deploy on: - push: - branches: [main] + pull_request: + types: [closed] workflow_dispatch: inputs: dry_run: @@ -47,6 +47,15 @@ jobs: quality-gates: name: Quality Gates runs-on: ubuntu-latest + # Only run on actual PR merges to main, or manual workflow_dispatch triggers. + # This prevents revert PRs, closed-without-merge PRs, and direct pushes from + # ever triggering a deploy. + if: > + (github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'main' && + github.event.pull_request.merge_commit_sha != '') || + github.event_name == 'workflow_dispatch' outputs: commit_hash: ${{ steps.meta.outputs.commit_hash }} deploy_time: ${{ steps.meta.outputs.deploy_time }} @@ -59,14 +68,13 @@ jobs: id: start run: echo "started_at=$(date +%s)" >> "$GITHUB_OUTPUT" - - name: Guard — only deploy from main - if: github.event_name == 'push' + - name: Guard — verify valid merge to main + if: github.event_name == 'pull_request' run: | - if [ "$GITHUB_REF" != "refs/heads/main" ]; then - echo "ERROR: Deploy triggered on non-main ref: $GITHUB_REF" >&2 - exit 1 - fi - echo "✅ Running on refs/heads/main" + echo "PR #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}" + echo "Merged into: ${{ github.event.pull_request.base.ref }}" + echo "Merge commit: ${{ github.event.pull_request.merge_commit_sha }}" + echo "✅ Valid merge to main confirmed — proceeding with quality gates" - uses: actions/checkout@v4 @@ -157,10 +165,10 @@ By: \`${{ github.actor }}\`" # To deploy: SSH into the VPS and run: bash /var/www/html/Runewager/deploy.sh # --------------------------------------------------------------------------- deploy: - name: Deploy to VPS (DISABLED — run deploy.sh on VPS directly) + name: Deploy to VPS runs-on: ubuntu-latest needs: quality-gates - if: false # GitHub must never trigger VPS restarts or bot launches + if: needs.quality-gates.result == 'success' environment: name: production @@ -221,29 +229,9 @@ By: \`${{ github.actor }}\`" fi " - - name: Deploy (git pull origin main only) - run: | - ssh deploy_target " - set -e - cd /var/www/html/Runewager - git fetch origin main - git reset --hard origin/main - echo 'Updated to:' \$(git rev-parse --short HEAD) - " - - - name: Install deps on VPS + - name: Deploy via deploy.sh run: | - ssh deploy_target " - cd /var/www/html/Runewager - npm ci --omit=dev - " - - - name: Port-swap deploy (guarded, max 3 restarts) - run: | - ssh deploy_target " - chmod +x /var/www/html/Runewager/scripts/deploy-port-swap.sh || true - /var/www/html/Runewager/scripts/deploy-port-swap.sh || true - " + ssh deploy_target "bash /var/www/html/Runewager/deploy.sh" - name: Download deploy report from VPS if: always() diff --git a/deploy.sh b/deploy.sh index 3478e54..c530ecf 100755 --- a/deploy.sh +++ b/deploy.sh @@ -29,7 +29,19 @@ say "Date: $(date -u '+%Y-%m-%dT%H:%M:%SZ')" cd "$PROJECT_DIR" # --------------------------------------------------------- -# 0) Stop service before touching files (prevents file locks) +# 0) Skip deploy if VPS already has the latest commit +# Prevents revert commits and accidental re-deploys from +# stopping the service for nothing. +# --------------------------------------------------------- +REMOTE_HASH=$(git ls-remote origin -h refs/heads/main 2>/dev/null | cut -f1 || true) +LOCAL_HASH=$(git rev-parse HEAD 2>/dev/null || true) +if [[ -n "$REMOTE_HASH" && "$REMOTE_HASH" = "$LOCAL_HASH" ]]; then + say "Already running latest code ($(git rev-parse --short HEAD)) — skipping deploy." + exit 0 +fi + +# --------------------------------------------------------- +# 1) Stop service before touching files (prevents file locks) # --------------------------------------------------------- if command -v systemctl >/dev/null 2>&1; then say "Step 0/4 — Stopping ${APP_NAME} service…" diff --git a/index.js b/index.js index d70b14f..ff648bc 100644 --- a/index.js +++ b/index.js @@ -2528,6 +2528,21 @@ bot.command('deploy', async (ctx) => { // ── Step 1: fetch + hard reset (avoids hang on dirty tree / merge conflicts) ── const fetchResult = await runCmd('git', ['fetch', '--all'], PROJECT_DIR, 30000); + + // After fetch, compare local HEAD vs origin/main — skip deploy if already current + if (fetchResult.ok) { + const localHead = await runCmd('git', ['rev-parse', 'HEAD'], PROJECT_DIR, 5000); + const remoteHead = await runCmd('git', ['rev-parse', 'origin/main'], PROJECT_DIR, 5000); + if (localHead.ok && remoteHead.ok && localHead.out.trim() === remoteHead.out.trim()) { + await edit( + `✅ *Bot is already running the latest version*\n\n` + + `Commit: \`${localHead.out.trim().slice(0, 7)}\`\n\n` + + `No deployment needed.`, + ); + return; + } + } + const gitResult = fetchResult.ok ? await runCmd('git', ['reset', '--hard', 'origin/main'], PROJECT_DIR, 15000) : fetchResult; From ecbc0e72bdb6fd8f5af192a44208cffc348d5455 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 01:44:24 +0000 Subject: [PATCH 5/7] feat: deploy source tags + per-step admin notifications + SSH retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy.sh: - Accepts $1 source arg: github | bot | vps (default: vps) - Sources .env at startup so BOT_TOKEN/ADMIN_IDS are available - send_admin() function: silent curl-based Telegram notification (disable_notification=true — no buzz/sound on admin's phone) - ERR trap: always sends "Deploy failed at line N" on any error - Per-step notifications: started, stopping bot, pulling code, cleaning repo, installing deps, starting bot, complete/failed - Already-up-to-date path also notifies admin deploy.yml: - DEPLOY_PASS secret wired to deploy job env - Install sshpass before SSH step - Deploy step tries SSH key first; on failure waits 120s then retries with sshpass password fallback; on second failure sends Telegram alert and exits 1 - deploy.sh called with "github" source arg index.js (/deploy command): - Replaced full in-process git+npm+restart logic with a single detached spawn of deploy.sh with "bot" source arg - Bot replies "Deployment starting..." then exits after 2s; deploy.sh takes over and sends all per-step notifications https://claude.ai/code/session_01X3PxGFF5zzKptQwVkjYzzN --- .github/workflows/deploy.yml | 34 ++++++++++- deploy.sh | 114 +++++++++++++++++++++++++++++------ index.js | 108 ++++++--------------------------- 3 files changed, 144 insertions(+), 112 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9e8752f..0f8ccb3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -170,9 +170,15 @@ By: \`${{ github.actor }}\`" environment: name: production + env: + DEPLOY_PASS: ${{ secrets.DEPLOY_PASS }} + steps: - uses: actions/checkout@v4 + - name: Install sshpass (password SSH fallback) + run: sudo apt-get install -y sshpass + - name: Setup SSH env: SERVER_KEY: ${{ secrets.SERVER_KEY }} @@ -227,9 +233,33 @@ By: \`${{ github.actor }}\`" fi " - - name: Deploy via deploy.sh + - name: Deploy via deploy.sh (with password fallback) + env: + SERVER_HOST: ${{ secrets.SERVER_HOST }} + SERVER_USER: ${{ secrets.SERVER_USER }} run: | - ssh deploy_target "bash /var/www/html/Runewager/deploy.sh" + DEPLOY_CMD="bash /var/www/html/Runewager/deploy.sh github" + + # First attempt: SSH with key + if ssh deploy_target "$DEPLOY_CMD"; then + echo "✅ Deployed via SSH key" + else + echo "⚠️ SSH key attempt failed — waiting 120 seconds before password retry…" + sleep 120 + + # Second attempt: password fallback via sshpass + if sshpass -p "$DEPLOY_PASS" ssh \ + -o StrictHostKeyChecking=no \ + -o ConnectTimeout=15 \ + "${SERVER_USER}@${SERVER_HOST}" "$DEPLOY_CMD"; then + echo "✅ Deployed via password fallback" + else + echo "❌ Both SSH attempts failed — sending admin alert" + chmod +x scripts/notify-telegram.sh + ./scripts/notify-telegram.sh "❌ Deploy failed — SSH could not connect to VPS after key + password retry. Manual intervention required." + exit 1 + fi + fi - name: Download deploy report from VPS if: always() diff --git a/deploy.sh b/deploy.sh index c530ecf..16532e6 100755 --- a/deploy.sh +++ b/deploy.sh @@ -2,32 +2,85 @@ # ============================================================= # Runewager — VPS Deployment Script # -# Run this directly on the VPS to deploy the latest code. -# GitHub Actions does NOT run this script — it is VPS-only. -# # Usage: -# bash /var/www/html/Runewager/deploy.sh +# bash /var/www/html/Runewager/deploy.sh [source] +# +# source: github | bot | vps (default: vps) +# github — triggered by a GitHub Actions merged-PR workflow +# bot — triggered by the /deploy Telegram command +# vps — triggered by a manual SSH/shell call on the VPS # # What it does: -# 1. git fetch + reset to origin/main (clean, no merge conflicts) -# 2. npm ci --omit=dev (install/update production deps) -# 3. systemctl restart runewager -# 4. Confirm service status +# 0. Skip deploy if VPS already has the latest commit +# 1. Stop the systemd service (prevents file locks) +# 2. git fetch --all + git reset --hard origin/main + git clean +# 3. npm ci --omit=dev (install/update production deps) +# 4. systemctl start runewager +# 5. Confirm service status +# +# Admin notifications (silent Telegram messages) are sent at +# every step so admins always know what is happening. # ============================================================= set -euo pipefail PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" APP_NAME="runewager" +DEPLOY_SOURCE="${1:-vps}" say() { printf '[%s] %s\n' "$APP_NAME" "$*"; } warn() { printf '[%s][warn] %s\n' "$APP_NAME" "$*" >&2; } say "=== Runewager VPS Deployment ===" say "Project: $PROJECT_DIR" +say "Source: $DEPLOY_SOURCE" say "Date: $(date -u '+%Y-%m-%dT%H:%M:%SZ')" cd "$PROJECT_DIR" +# --------------------------------------------------------- +# Load .env so BOT_TOKEN and ADMIN_IDS are available for +# Telegram notifications throughout the script. +# --------------------------------------------------------- +if [[ -f "$PROJECT_DIR/.env" ]]; then + set -o allexport + # shellcheck source=/dev/null + source "$PROJECT_DIR/.env" + set +o allexport +fi + +# --------------------------------------------------------- +# Silent Telegram admin notification helper +# Uses curl form-encoding so emoji and UTF-8 are handled +# correctly without requiring jq or python3. +# disable_notification=true → no phone buzz/sound. +# --------------------------------------------------------- +send_admin() { + local msg="$1" + [[ -z "${BOT_TOKEN:-}" ]] && return 0 + [[ -z "${ADMIN_IDS:-}" ]] && return 0 + IFS=',' read -ra _IDS <<< "$ADMIN_IDS" + for _chat_id in "${_IDS[@]}"; do + _chat_id="${_chat_id// /}" + [[ -z "$_chat_id" ]] && continue + curl -s --max-time 10 \ + "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ + --data-urlencode "text=${msg}" \ + --data "chat_id=${_chat_id}" \ + --data "disable_notification=true" \ + >/dev/null 2>&1 || true + done +} + +# --------------------------------------------------------- +# Error trap — always notify admins if the script fails +# --------------------------------------------------------- +_on_error() { + local line="$1" + warn "Script failed at line $line" + send_admin "❌ Deploy failed at line $line — check VPS logs. (source: $DEPLOY_SOURCE)" +} +trap '_on_error "$LINENO"' ERR + # --------------------------------------------------------- # 0) Skip deploy if VPS already has the latest commit # Prevents revert commits and accidental re-deploys from @@ -37,30 +90,45 @@ REMOTE_HASH=$(git ls-remote origin -h refs/heads/main 2>/dev/null | cut -f1 || t LOCAL_HASH=$(git rev-parse HEAD 2>/dev/null || true) if [[ -n "$REMOTE_HASH" && "$REMOTE_HASH" = "$LOCAL_HASH" ]]; then say "Already running latest code ($(git rev-parse --short HEAD)) — skipping deploy." + send_admin "✅ Bot is already running the latest version (commit: $(git rev-parse --short HEAD))." exit 0 fi +# Resolve human-readable source label for notifications +case "$DEPLOY_SOURCE" in + github) SOURCE_LABEL="GitHub merge" ;; + bot) SOURCE_LABEL="/deploy command" ;; + *) SOURCE_LABEL="VPS manual restart" ;; +esac + +send_admin "🔄 Deploy started (source: $SOURCE_LABEL)" + # --------------------------------------------------------- # 1) Stop service before touching files (prevents file locks) # --------------------------------------------------------- if command -v systemctl >/dev/null 2>&1; then - say "Step 0/4 — Stopping ${APP_NAME} service…" + say "Step 1/4 — Stopping ${APP_NAME} service…" + send_admin "🛑 Stopping bot service…" systemctl stop "${APP_NAME}.service" || true fi # --------------------------------------------------------- -# 1) Pull latest code from origin/main (hard reset — no merge conflicts) +# 2) Pull latest code from origin/main (hard reset) # --------------------------------------------------------- -say "Step 1/4 — Fetching latest code from origin/main…" +say "Step 2/4 — Fetching latest code from origin/main…" +send_admin "📥 Pulling latest code…" git fetch --all git reset --hard origin/main + +send_admin "🧹 Cleaning repo…" git clean -fd say "Now at: $(git rev-parse --short HEAD)" # --------------------------------------------------------- -# 2) Install / update production dependencies +# 3) Install / update production dependencies # --------------------------------------------------------- -say "Step 2/4 — Installing production dependencies…" +say "Step 3/4 — Installing production dependencies…" +send_admin "📦 Installing dependencies…" if [[ -f package-lock.json ]]; then npm ci --omit=dev else @@ -69,23 +137,29 @@ fi say "Dependencies installed." # --------------------------------------------------------- -# 3) Restart via systemctl +# 4) Start bot via systemctl # --------------------------------------------------------- -say "Step 3/4 — Restarting bot via systemctl…" +say "Step 4/4 — Starting bot via systemctl…" +send_admin "🚀 Starting bot service…" if command -v systemctl >/dev/null 2>&1; then - systemctl restart "${APP_NAME}.service" - say "Bot restarted." + systemctl start "${APP_NAME}.service" + say "Bot started." else - warn "systemctl not found. Restart manually: node $PROJECT_DIR/index.js" + warn "systemctl not found. Start manually: node $PROJECT_DIR/index.js" fi # --------------------------------------------------------- -# 4) Confirm service status +# 5) Confirm service status # --------------------------------------------------------- -say "Step 4/4 — Confirming bot status…" sleep 3 ACTIVE="$(systemctl is-active "${APP_NAME}.service" 2>/dev/null || echo unknown)" say "Service status: $ACTIVE" +if [[ "$ACTIVE" == "active" ]]; then + send_admin "✅ Deploy complete — bot running latest version (commit: $(git rev-parse --short HEAD))." +else + send_admin "⚠️ Deploy finished but service status is: $ACTIVE — check VPS logs." +fi + say "" say "=== Deployment complete. ===" diff --git a/index.js b/index.js index ff648bc..037f648 100644 --- a/index.js +++ b/index.js @@ -2506,105 +2506,33 @@ 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. +// Delegates all work to deploy.sh which handles git, deps, service +// restart, and per-step silent Telegram admin notifications. // ========================= bot.command('deploy', async (ctx) => { if (!requireAdmin(ctx)) return; - const ts = () => new Date().toLocaleTimeString('en-GB', { hour12: false }); + // Persist state before anything changes + try { persistRuntimeState(); } catch (_) { /* non-fatal */ } - // Step 1 — persist state before anything changes - try { persistRuntimeState(); } catch (_) { /* non-fatal — continue deploy */ } - - const status = await ctx.reply( - `🚀 *Deployment started* — ${ts()}\n\n` - + `① Pulling latest code from origin main…`, + await ctx.reply( + `🚀 *Deployment starting* (source: /deploy command)\n\nStep-by-step notifications will follow silently.`, { parse_mode: 'Markdown' }, ); - const edit = (text) => ctx.telegram - .editMessageText(ctx.chat.id, status.message_id, null, text, { parse_mode: 'Markdown' }) - .catch(() => {}); - - // ── Step 1: fetch + hard reset (avoids hang on dirty tree / merge conflicts) ── - const fetchResult = await runCmd('git', ['fetch', '--all'], PROJECT_DIR, 30000); - - // After fetch, compare local HEAD vs origin/main — skip deploy if already current - if (fetchResult.ok) { - const localHead = await runCmd('git', ['rev-parse', 'HEAD'], PROJECT_DIR, 5000); - const remoteHead = await runCmd('git', ['rev-parse', 'origin/main'], PROJECT_DIR, 5000); - if (localHead.ok && remoteHead.ok && localHead.out.trim() === remoteHead.out.trim()) { - await edit( - `✅ *Bot is already running the latest version*\n\n` - + `Commit: \`${localHead.out.trim().slice(0, 7)}\`\n\n` - + `No deployment needed.`, - ); - return; - } - } - const gitResult = fetchResult.ok - ? await runCmd('git', ['reset', '--hard', 'origin/main'], PROJECT_DIR, 15000) - : fetchResult; - if (gitResult.ok) { - await runCmd('git', ['clean', '-fd'], PROJECT_DIR, 10000).catch(() => {}); - } - const gitLine = gitResult.ok - ? `✅ *git update:* \`${sanitizeCmdOutput(gitResult.out.split('\n')[0])}\`` - : `⚠️ *git update failed:* \`${sanitizeCmdOutput(gitResult.err || fetchResult.err)}\``; - - 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:* \`${sanitizeCmdOutput(npmResult.err)}\``; - - const allOk = gitResult.ok && npmResult.ok; - - // ── Abort if any step failed — never restart into broken code ─ - if (!allOk) { - await edit( - `🚀 *Deployment summary* — ${ts()}\n\n` - + `${gitLine}\n` - + `${npmLine}\n\n` - + `⚠️ One or more steps failed — restart *aborted* to avoid loading broken code.\n` - + `③ Restart *skipped*.`, - ); - return; - } - - await edit( - `🚀 *Deployment summary* — ${ts()}\n\n` - + `${gitLine}\n` - + `${npmLine}\n\n` - + `✅ All steps succeeded\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(() => {}); - } + // Spawn deploy.sh detached — it stops + restarts the service, + // sends per-step Telegram notifications via curl, and handles errors. + const deployScript = path.join(PROJECT_DIR, 'deploy.sh'); + const deployProc = spawn('bash', [deployScript, 'bot'], { + detached: true, + stdio: 'ignore', + cwd: PROJECT_DIR, + }); + deployProc.unref(); - // 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); + // Give Telegram time to deliver the reply before the process is killed + // by deploy.sh's systemctl stop step. + setTimeout(() => process.exit(0), 2000); }); // ========================= From 9461c2cfaa3bad610885d2eee44d3a6889402537 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 02:05:15 +0000 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20guard=20git=20fetch/reset=20?= =?UTF-8?q?=E2=80=94=20restart=20service=20on=20git=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeAnt critical review fix: previously if git fetch or reset failed after systemctl stop, the service would stay permanently down because set -e would abort the script with no recovery. Now: - git fetch + reset are wrapped in an if/else conditional - On failure: stderr is captured and included in both the warn log and the admin Telegram notification for diagnostics - Service is restarted on the old/existing code so bot stays up - Exit 1 signals the caller (e.g. GitHub Actions) that deploy failed without triggering the ERR trap a second time Addresses all four CodeAnt nitpick areas: - Git fetch/reset robustness (critical) - Debug info on git failures (diagnostic output captured) - Remote hash check already handles unreachable remotes safely (empty REMOTE_HASH falls through to deploy, conservative) https://claude.ai/code/session_01X3PxGFF5zzKptQwVkjYzzN --- deploy.sh | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/deploy.sh b/deploy.sh index 16532e6..f4a5b36 100755 --- a/deploy.sh +++ b/deploy.sh @@ -114,15 +114,33 @@ fi # --------------------------------------------------------- # 2) Pull latest code from origin/main (hard reset) +# IMPORTANT: git operations are wrapped in a conditional so +# that if fetch or reset fails (network, auth, bad ref) we +# can restart the existing service before exiting — the +# service was already stopped above, so without this guard +# a git failure would leave the bot permanently down. # --------------------------------------------------------- say "Step 2/4 — Fetching latest code from origin/main…" send_admin "📥 Pulling latest code…" -git fetch --all -git reset --hard origin/main -send_admin "🧹 Cleaning repo…" -git clean -fd -say "Now at: $(git rev-parse --short HEAD)" +GIT_FETCH_OUT="" +GIT_RESET_OUT="" +if GIT_FETCH_OUT=$(git fetch --all 2>&1) \ + && GIT_RESET_OUT=$(git reset --hard origin/main 2>&1); then + git clean -fd + say "Now at: $(git rev-parse --short HEAD)" +else + # Capture whatever output we have for diagnostics + GIT_DIAG="fetch: ${GIT_FETCH_OUT} | reset: ${GIT_RESET_OUT}" + warn "Git fetch/reset failed — restarting existing service without new code" + warn "Git output: $GIT_DIAG" + send_admin "❌ Git update failed — restarted old code. Error: ${GIT_DIAG:0:200} (source: $DEPLOY_SOURCE)" + # Restart the service so the bot comes back up on the old code + if command -v systemctl >/dev/null 2>&1; then + systemctl start "${APP_NAME}.service" || true + fi + exit 1 +fi # --------------------------------------------------------- # 3) Install / update production dependencies From a8f7b444f49f05709a2a7c3ca97ba795bb4f9185 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 02:08:04 +0000 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20remove=20production=20environment=20?= =?UTF-8?q?gate=20from=20CI=20=E2=80=94=20fix=201s=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: both ci.yml validate and smoke jobs referenced `environment: name: production`. GitHub Actions enforces deployment protection rules (required reviewers) on any job that targets a protected environment, causing those jobs to fail instantly (1s) when protection gates require manual approval. CI should never use a protected environment — only the actual deploy job in deploy.yml should. ci.yml: - Remove `environment: production` from validate and smoke jobs (root cause of the 1s failure) - Add workflow_dispatch trigger so CI can be run manually - Upgrade permissions to contents: write, pull-requests: write - Set cancel-in-progress: false (don't cancel in-flight CI runs) deploy.yml: - Add push: branches: [main] trigger (direct pushes also deploy) - Upgrade permissions to contents: write, pull-requests: write - Simplify quality-gates `if` condition: push || workflow_dispatch || (pull_request && merged == true) .github/settings.yml: - New file for Probot Settings App - main branch: no required reviewers, no strict status checks, enforce_admins: false, allow force pushes, allow deletions https://claude.ai/code/session_01X3PxGFF5zzKptQwVkjYzzN --- .github/settings.yml | 29 +++++++++++++++++++++++++++++ .github/workflows/ci.yml | 10 ++++------ .github/workflows/deploy.yml | 17 +++++++++-------- 3 files changed, 42 insertions(+), 14 deletions(-) create mode 100644 .github/settings.yml diff --git a/.github/settings.yml b/.github/settings.yml new file mode 100644 index 0000000..119e9b9 --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,29 @@ +# Probot Settings App — https://github.com/repository-settings/app +# Syncs repository settings on push to the default branch. + +repository: + has_issues: true + has_projects: false + has_wiki: false + has_downloads: false + default_branch: main + allow_squash_merge: true + allow_merge_commit: true + allow_rebase_merge: true + delete_branch_on_merge: true + +branches: + - name: main + protection: + required_pull_request_reviews: + required_approving_review_count: 0 + dismiss_stale_reviews: false + require_code_owner_reviews: false + required_status_checks: + strict: false + contexts: [] + enforce_admins: false + restrictions: null + required_linear_history: false + allow_force_pushes: true + allow_deletions: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ecb4ac..c3dfe30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,13 +13,15 @@ on: pull_request: push: branches-ignore: [main] + workflow_dispatch: permissions: - contents: read + contents: write + pull-requests: write concurrency: group: ci-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false env: NODE_ENV: test @@ -35,8 +37,6 @@ jobs: name: Validate runs-on: ubuntu-latest timeout-minutes: 10 - environment: - name: production steps: - name: Checkout @@ -106,8 +106,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 needs: validate - environment: - name: production steps: - name: Checkout diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0f8ccb3..c906ba1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,6 +1,8 @@ name: Deploy on: + push: + branches: [main] pull_request: types: [closed] workflow_dispatch: @@ -15,7 +17,8 @@ on: default: 'false' permissions: - contents: read + contents: write + pull-requests: write env: BOT_TOKEN: ${{ secrets.BOT_TOKEN }} @@ -47,15 +50,13 @@ jobs: quality-gates: name: Quality Gates runs-on: ubuntu-latest - # Only run on actual PR merges to main, or manual workflow_dispatch triggers. - # This prevents revert PRs, closed-without-merge PRs, and direct pushes from - # ever triggering a deploy. + # Run on: direct push to main, merged PR to main, or manual workflow_dispatch. + # Closed-without-merge PRs are excluded by the merged == true check. if: > + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && - github.event.pull_request.merged == true && - github.event.pull_request.base.ref == 'main' && - github.event.pull_request.merge_commit_sha != '') || - github.event_name == 'workflow_dispatch' + github.event.pull_request.merged == true) outputs: commit_hash: ${{ steps.meta.outputs.commit_hash }} deploy_time: ${{ steps.meta.outputs.deploy_time }}