diff --git a/index.js b/index.js index d76b842..1472f84 100644 --- a/index.js +++ b/index.js @@ -1720,6 +1720,8 @@ function adminPromoToolsKeyboard() { [Markup.button.callback('๐Ÿ”ข Edit Claim Limit', 'admin_edit_limit')], [Markup.button.callback('โธ Pause Promo', 'admin_pause'), Markup.button.callback('โ–ถ๏ธ Unpause', 'admin_unpause')], [Markup.button.callback('๐Ÿ“ข Broadcast Update', 'admin_broadcast')], + [Markup.button.callback('๐Ÿ“ฃ Announcements', 'admin_cmd_announce_start')], + [Markup.button.callback('๐Ÿ’ก Tips Manager', 'admin_cmd_tips_dashboard')], [Markup.button.callback('โฌ…๏ธ Admin Dashboard', 'open_admin_dashboard')], ]); } @@ -1741,6 +1743,7 @@ function adminSystemToolsKeyboard() { [Markup.button.callback('๐Ÿ“Ÿ VPS Console (/sshv)', 'sshv_open')], [Markup.button.callback('๐Ÿฉบ Health Check', 'admin_cmd_health')], [Markup.button.callback('๐Ÿ“ฆ Bot Version', 'admin_cmd_version')], + [Markup.button.callback('๐Ÿงช Tip Test (4h broadcast)', 'admin_cmd_tiptest')], [Markup.button.callback('๐Ÿ’พ Backup State', 'admin_backup_action')], [Markup.button.callback('๐Ÿ”˜ Admin Mode On', 'admin_cmd_mode_on'), Markup.button.callback('โšช Admin Mode Off', 'admin_cmd_mode_off')], [Markup.button.callback('โฌ…๏ธ Admin Dashboard', 'open_admin_dashboard')], @@ -1814,6 +1817,22 @@ async function sendAdminDashboard(ctx, page = 1) { ); } +async function replaceCallbackPanel(ctx, text, extra = {}) { + try { + await ctx.editMessageText(text, extra); + return; + } catch (_) { + // fallback to delete + send when source message is not editable + } + + if (ctx.callbackQuery && ctx.callbackQuery.message) { + try { + await ctx.telegram.deleteMessage(ctx.callbackQuery.message.chat.id, ctx.callbackQuery.message.message_id); + } catch (_) { /* ignore */ } + } + await ctx.reply(text, extra); +} + async function sendSettingsMenu(ctx, user) { const s = user.settings; const lines = [ @@ -3316,6 +3335,36 @@ bot.command('sshv', async (ctx) => { await renderSshvConsole(ctx, session, note); }); +bot.action('admin_cmd_announce_start', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + clearPendingAction(user); + user.pendingAction = { type: 'await_announcement_text' }; + await ctx.answerCbQuery('Send announcement text'); + await replaceCallbackPanel(ctx, '๐Ÿ“ฃ *Announcement Composer*\n\nSend the announcement text now. You can use normal text or HTML.', { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([[Markup.button.callback('โŒ Cancel', 'open_admin_dashboard')]]), + }); +}); + +bot.action('admin_cmd_tips_dashboard', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await sendTipsDashboard(ctx); +}); + +bot.action('admin_cmd_tiptest', 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 4-hour tip preview:\n\n${tip.text}`, { parse_mode: 'Markdown' }); +}); + // ========================= // Admin Announce Command โ€” /A /a /announce // State machine: @@ -4090,7 +4139,7 @@ bot.action('pmenu_claim_bonus', async (ctx) => { // Show bonus options as temporary message then reappear persistent menu const wb = user.wagerBonus; const canRequest30 = wb.attempts < 3 && !isWagerBonusRequestLocked(wb.status); - await ctx.reply( + await replaceCallbackPanel(ctx, '๐ŸŽ *Bonus Options*\n\n' + 'โ€ข *New User Bonus* โ€” Claim signup promo code (once per account)\n' + 'โ€ข *30 SC Wager Bonus* โ€” Submit your wager proof for 30 SC', @@ -4124,7 +4173,7 @@ bot.action('pmenu_my_profile', async (ctx) => { `Promo claimed: ${user.claimedPromo ? 'โœ…' : 'โŒ'}`, `XP: ${user.profileXP || 0}`, ]; - await ctx.reply(lines.join('\n'), { + await replaceCallbackPanel(ctx, lines.join('\n'), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([ [Markup.button.callback('๐Ÿ”— Link Runewager Username', 'menu_link_runewager')], @@ -4170,7 +4219,7 @@ bot.action('pmenu_help', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); // Show help sub-menu: Command Help Booklet + Bug Report - await ctx.reply( + await replaceCallbackPanel(ctx, 'โ“ *Help & Support*\n\nChoose what you need:', { parse_mode: 'Markdown', @@ -4249,7 +4298,7 @@ bot.action('pamenu_stats_24h', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); const s = buildStatsWindow(24 * 60 * 60 * 1000); - await ctx.reply(buildStatsText('Last 24 hours', 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โ†ฉ Admin Menu', 'pamenu_back_admin')]]) }); + await replaceCallbackPanel(ctx, buildStatsText('Last 24 hours', 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โ†ฉ Admin Menu', 'pamenu_back_admin')]]) }); await sendPersistentAdminMenu(ctx, user); }); @@ -4257,7 +4306,7 @@ bot.action('pamenu_stats_7d', async (ctx) => { await ctx.answerCbQuery(); if (!requireAdmin(ctx)) return; const user = getUser(ctx); - await ctx.reply(buildStatsText('Last 7 days', 7 * 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โ†ฉ Admin Menu', 'pamenu_back_admin')]]) }); + await replaceCallbackPanel(ctx, buildStatsText('Last 7 days', 7 * 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โ†ฉ Admin Menu', 'pamenu_back_admin')]]) }); await sendPersistentAdminMenu(ctx, user); }); @@ -4265,7 +4314,7 @@ bot.action('pamenu_stats_30d', async (ctx) => { await ctx.answerCbQuery(); if (!requireAdmin(ctx)) return; const user = getUser(ctx); - await ctx.reply(buildStatsText('Last 30 days', 30 * 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โ†ฉ Admin Menu', 'pamenu_back_admin')]]) }); + await replaceCallbackPanel(ctx, buildStatsText('Last 30 days', 30 * 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โ†ฉ Admin Menu', 'pamenu_back_admin')]]) }); await sendPersistentAdminMenu(ctx, user); }); @@ -5097,31 +5146,31 @@ bot.action('admin_dash_page_2', async (ctx) => { bot.action('admin_cat_giveaway', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('๐ŸŽ‰ *Giveaway Tools*', { parse_mode: 'Markdown', ...adminGiveawayToolsKeyboard() }); + await replaceCallbackPanel(ctx, '๐ŸŽ‰ *Giveaway Tools*', { parse_mode: 'Markdown', ...adminGiveawayToolsKeyboard() }); }); bot.action('admin_cat_promo', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('๐Ÿ“ฃ *Promo Tools*', { parse_mode: 'Markdown', ...adminPromoToolsKeyboard() }); + await replaceCallbackPanel(ctx, '๐Ÿ“ฃ *Promo Tools*', { parse_mode: 'Markdown', ...adminPromoToolsKeyboard() }); }); bot.action('admin_cat_user', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('๐Ÿ‘ค *User Tools*', { parse_mode: 'Markdown', ...adminUserToolsKeyboard() }); + await replaceCallbackPanel(ctx, '๐Ÿ‘ค *User Tools*', { parse_mode: 'Markdown', ...adminUserToolsKeyboard() }); }); bot.action('admin_cat_system', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('โš™๏ธ *System Tools*', { parse_mode: 'Markdown', ...adminSystemToolsKeyboard() }); + await replaceCallbackPanel(ctx, 'โš™๏ธ *System Tools*', { parse_mode: 'Markdown', ...adminSystemToolsKeyboard() }); }); bot.action('admin_cat_support', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('๐Ÿ›Ÿ *Support Tools*', { parse_mode: 'Markdown', ...adminSupportToolsKeyboard() }); + await replaceCallbackPanel(ctx, '๐Ÿ›Ÿ *Support Tools*', { parse_mode: 'Markdown', ...adminSupportToolsKeyboard() }); }); // Inline triggers for admin dashboard quick-action buttons @@ -5439,13 +5488,13 @@ function buildStatsText(label, windowMs) { bot.action('admin_stats_menu', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('๐Ÿ“ˆ *Stats โ€” Select Time Window*', { parse_mode: 'Markdown', ...adminStatsKeyboard() }); + await replaceCallbackPanel(ctx, '๐Ÿ“ˆ *Stats โ€” Select Time Window*', { parse_mode: 'Markdown', ...adminStatsKeyboard() }); }); bot.action('admin_stats_24h', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply(buildStatsText('Last 24 hours', 24 * 60 * 60 * 1000), { + await replaceCallbackPanel(ctx, buildStatsText('Last 24 hours', 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โฌ…๏ธ Back', 'admin_stats_menu')]]), }); @@ -5454,7 +5503,7 @@ bot.action('admin_stats_24h', async (ctx) => { bot.action('admin_stats_7d', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply(buildStatsText('Last 7 days', 7 * 24 * 60 * 60 * 1000), { + await replaceCallbackPanel(ctx, buildStatsText('Last 7 days', 7 * 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โฌ…๏ธ Back', 'admin_stats_menu')]]), }); @@ -5463,7 +5512,7 @@ bot.action('admin_stats_7d', async (ctx) => { bot.action('admin_stats_30d', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply(buildStatsText('Last 30 days', 30 * 24 * 60 * 60 * 1000), { + await replaceCallbackPanel(ctx, buildStatsText('Last 30 days', 30 * 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โฌ…๏ธ Back', 'admin_stats_menu')]]), }); @@ -5472,7 +5521,7 @@ bot.action('admin_stats_30d', async (ctx) => { bot.action('admin_stats_lifetime', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply(buildStatsText('Lifetime', 0), { + await replaceCallbackPanel(ctx, buildStatsText('Lifetime', 0), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โฌ…๏ธ Back', 'admin_stats_menu')]]), }); @@ -5622,29 +5671,31 @@ bot.action('admin_edit_limit', async (ctx) => { bot.action('admin_pause', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('Pause the current promo?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Pause', 'admin_pause_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); + await replaceCallbackPanel(ctx, 'Pause the current promo?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Pause', 'admin_pause_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); }); bot.action('admin_unpause', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('Unpause the promo?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Unpause', 'admin_unpause_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); + await replaceCallbackPanel(ctx, 'Unpause the promo?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Unpause', 'admin_unpause_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); }); bot.action('admin_remove', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('Are you sure you want to remove the promo?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Remove', 'admin_remove_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); + await replaceCallbackPanel(ctx, 'Are you sure you want to remove the promo?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Remove', 'admin_remove_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); }); bot.action('admin_broadcast', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('Send updated promo to all users?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Broadcast', 'admin_broadcast_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); + await replaceCallbackPanel(ctx, 'Send updated promo to all users?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Broadcast', 'admin_broadcast_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); }); bot.action('admin_cancel', async (ctx) => { + if (!requireAdmin(ctx)) return; await ctx.answerCbQuery('Cancelled'); + await replaceCallbackPanel(ctx, 'โœ… Cancelled. Select another promo action:', { parse_mode: 'Markdown', ...adminPromoToolsKeyboard() }); }); bot.action('admin_pause_yes', async (ctx) => { diff --git a/prod-run.sh b/prod-run.sh index 0f5b360..f6a378f 100755 --- a/prod-run.sh +++ b/prod-run.sh @@ -129,6 +129,47 @@ is_port_listening() { return 1 } +port_listener_pids() { + local port="$1" + + if command -v ss >/dev/null 2>&1; then + ss -ltnp "sport = :${port}" 2>/dev/null | sed -n 's/.*pid=\([0-9]\+\).*/\1/p' | sort -u + return + fi + + if command -v lsof >/dev/null 2>&1; then + lsof -t -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null | sort -u + return + fi + + if command -v fuser >/dev/null 2>&1; then + fuser -n tcp "${port}" 2>/dev/null | tr ' ' '\n' | sed '/^$/d' | sort -u + fi +} + +free_port_if_conflicted() { + local port="$1" + local bot_pid="$2" + local killed=0 + local pid + + while IFS= read -r pid; do + [[ -z "$pid" ]] && continue + if [[ -n "$bot_pid" && "$pid" == "$bot_pid" ]]; then + continue + fi + warn "Killing process $pid occupying port ${port}" + kill -TERM "$pid" 2>/dev/null || true + sleep 1 + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || true + fi + killed=1 + done < <(port_listener_pids "$port") + + return $killed +} + say "Starting production runner" say "Project directory: $PROJECT_DIR" say "Running as: $(id -un) (uid=$(id -u) gid=$(id -g))" @@ -405,6 +446,41 @@ else HEALTH_STATUS="unhealthy" fi +PRECHECK_HEALTH_URL="$(resolve_health_url)" +PRECHECK_PORT="${PRECHECK_HEALTH_URL#*://127.0.0.1:}" +PRECHECK_PORT="${PRECHECK_PORT%%/*}" +if is_port_listening "$PRECHECK_PORT"; then + free_port_if_conflicted "$PRECHECK_PORT" "$PID" || true +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 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 + sleep 3 + PID="$(get_bot_pid)" + fi +else + PORT_STATUS="not-listening" +fi + +SYSTEMD_ACTIVE="$(systemctl is-active "${APP_NAME}.service" 2>/dev/null || echo inactive)" +HEALTH_URL="$(resolve_health_url)" +HEALTH_PORT="${HEALTH_URL#*://127.0.0.1:}" +HEALTH_PORT="${HEALTH_PORT%%/*}" + +if wait_for_health "$HEALTH_URL" 20 2; then + HEALTH_STATUS="healthy" +else + HEALTH_STATUS="unhealthy" +fi + if is_port_listening "$HEALTH_PORT"; then PORT_STATUS="listening" else @@ -420,6 +496,23 @@ if wait_for_health "$HEALTH_URL" 20 2; then HEALTH_STATUS="healthy" else HEALTH_STATUS="unhealthy" + + # Auto-recover from port conflicts, then retry health once. + if is_port_listening "$HEALTH_PORT"; then + free_port_if_conflicted "$HEALTH_PORT" "$PID" || true + if command -v systemctl >/dev/null 2>&1 && [[ -f "$SERVICE_FILE" ]]; then + systemctl restart "${APP_NAME}.service" 2>/dev/null || true + else + [[ -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 + fi + sleep 3 + PID="$(get_bot_pid)" + if wait_for_health "$HEALTH_URL" 10 2; then + HEALTH_STATUS="healthy" + fi + fi fi if is_port_listening "$HEALTH_PORT"; then