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
201 changes: 198 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The /deploy command restarts the bot process even when git pull or npm ci fail, meaning it can deliberately restart into a broken or partially updated state contrary to the intended "pull, install, then restart" flow, likely causing the new process to crash or misbehave until manually fixed. [logic error]

Severity Level: Critical 🚨
- ❌ /deploy can restart bot after failed git or npm steps.
- ⚠️ Bot may restart into inconsistent or broken deployment state.
Suggested change
await edit(
if (!allOk) {
await edit(
`🚀 *Deployment summary* — ${ts()}\n\n`
+ `${gitLine}\n`
+ `${npmLine}\n\n`
+ `${summary}\n\n`
+ `③ Restart skipped — one or more steps failed.`,
);
return;
}
Steps of Reproduction ✅
1. As an admin (ID present in `ADMIN_IDS` from `index.js:14-18`), run the `/deploy`
command in Telegram, which executes the handler defined at `index.js:1216-1280`
(`bot.command('deploy', async (ctx) => { ... })` after passing `requireAdmin(ctx)` at
`index.js:1200-1207`).

2. In an environment where `git pull` fails (e.g. merge conflict, network issue, or local
changes), the call `runCmd('git', ['pull', 'origin', 'main'], PROJECT_DIR, 30000)` at
`index.js:1234` returns `{ ok: false, err: '...' }`, so `gitResult.ok` is `false` and
`gitLine` describes the failure.

3. The subsequent `npm ci` step runs via `runCmd('npm', ['ci', '--omit=dev'], PROJECT_DIR,
180000)` at `index.js:1245`; regardless of whether `npmResult.ok` is true or false, the
expression `const allOk = gitResult.ok && npmResult.ok;` at `index.js:1250` evaluates to
`false` because `gitResult.ok` is already `false`.

4. Despite `allOk` being `false`, the code immediately edits the status message with `③
Restarting bot now…` (lines `1253-1259`) and then executes the restart block at
`index.js:1261-1279`, sending a restart note to all admins and calling `spawn('systemctl',
['restart', 'runewager'], ...)` followed by `process.exit(0)`; the bot process is
restarted even though the codebase or dependencies were not successfully updated, risking
a new process that fails to start correctly or runs with mismatched code/deps.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** index.js
**Line:** 1253:1253
**Comment:**
	*Logic Error: The `/deploy` command restarts the bot process even when `git pull` or `npm ci` fail, meaning it can deliberately restart into a broken or partially updated state contrary to the intended "pull, install, then restart" flow, likely causing the new process to crash or misbehave until manually fixed.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

`🚀 *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
// =========================
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
70 changes: 49 additions & 21 deletions prod-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -282,33 +306,37 @@ 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
sleep 3
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

# ---------------------------------------------------------
Expand Down