Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 71 additions & 20 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')],
]);
}
Expand All @@ -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')],
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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')],
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -4249,23 +4298,23 @@ 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);
});

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);
});

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);
});

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')]]),
});
Expand All @@ -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')]]),
});
Expand All @@ -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')]]),
});
Expand All @@ -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')]]),
});
Expand Down Expand Up @@ -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) => {
Expand Down
93 changes: 93 additions & 0 deletions prod-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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))"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading