diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c906ba1..e4dbcd5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -151,25 +151,25 @@ jobs: BOT_TOKEN: ${{ secrets.BOT_TOKEN }} run: | chmod +x scripts/notify-telegram.sh - ./scripts/notify-telegram.sh "๐Ÿš€ *Deploy Starting* -Repo: \`${{ github.repository }}\` -Branch: \`${{ github.ref_name }}\` -Commit: \`${{ steps.meta.outputs.commit_hash }}\` -Version: \`${{ steps.meta.outputs.version }}\` -Mode: \`${DEPLOY_MODE}\` -By: \`${{ github.actor }}\`" + MSG="๐Ÿš€ *Deploy Starting*"$'\n' + MSG+="Repo: \`${{ github.repository }}\`"$'\n' + MSG+="Branch: \`${{ github.ref_name }}\`"$'\n' + MSG+="Commit: \`${{ steps.meta.outputs.commit_hash }}\`"$'\n' + MSG+="Version: \`${{ steps.meta.outputs.version }}\`"$'\n' + MSG+="Mode: \`${DEPLOY_MODE}\`"$'\n' + MSG+="By: \`${{ github.actor }}\`" + ./scripts/notify-telegram.sh "$MSG" # --------------------------------------------------------------------------- - # Job 2: Deploy to VPS โ€” SSH into VPS and run deploy.sh + # Job 2: Deploy to VPS โ€” pull latest main, restart service, health check # Only triggers after quality gates pass on a valid PR merge to main. # --------------------------------------------------------------------------- deploy: name: Deploy to VPS runs-on: ubuntu-latest needs: quality-gates + environment: Production if: needs.quality-gates.result == 'success' - environment: - name: production env: DEPLOY_PASS: ${{ secrets.DEPLOY_PASS }} @@ -177,7 +177,7 @@ By: \`${{ github.actor }}\`" steps: - uses: actions/checkout@v4 - - name: Install sshpass (password SSH fallback) + - name: Install sshpass (password fallback) run: sudo apt-get install -y sshpass - name: Setup SSH @@ -187,9 +187,12 @@ By: \`${{ github.actor }}\`" run: | mkdir -p ~/.ssh chmod 700 ~/.ssh + printf '%s\n' "$SERVER_KEY" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts + cat >> ~/.ssh/config <> "$GITHUB_OUTPUT" - name: Pre-deploy health snapshot + env: + BOT_TOKEN: ${{ secrets.BOT_TOKEN }} run: | ssh deploy_target " set -e @@ -223,41 +241,81 @@ By: \`${{ github.actor }}\`" echo 'Checking memory/disk...' free -m || true df -h . || true - - echo 'Checking disk free threshold...' - FREE_KB=\$(df . | awk 'NR==2 {print \$4}') - FREE_MB=\$((FREE_KB / 1024)) - if [ \"\$FREE_MB\" -lt 100 ]; then - echo 'WARNING: Less than 100MB free disk space (' \$FREE_MB 'MB). Proceeding with caution.' - else - echo \"โœ… Disk: \${FREE_MB}MB free\" - fi " - - name: Deploy via deploy.sh (with password fallback) + chmod +x scripts/notify-telegram.sh + MSG="๐Ÿ“‹ *Pre-Deploy Health Snapshot*"$'\n' + MSG+="Path: \`/var/www/html/Runewager\`"$'\n' + MSG+="Commit (pre): \`${{ steps.pre.outputs.sha }}\`" + ./scripts/notify-telegram.sh "$MSG" + + - name: Deploy via deploy.sh (key first, then password fallback) env: SERVER_HOST: ${{ secrets.SERVER_HOST }} SERVER_USER: ${{ secrets.SERVER_USER }} + BOT_TOKEN: ${{ secrets.BOT_TOKEN }} run: | - DEPLOY_CMD="bash /var/www/html/Runewager/deploy.sh github" + chmod +x scripts/notify-telegram.sh + + DEPLOY_CMD=' + set -e + cd /var/www/html/Runewager + + echo "Pulling latest main..." + git fetch origin main + git reset --hard origin/main + + echo "Ensuring .env exists and is preserved..." + if [ ! -f .env ]; then + echo "ERROR: .env missing on VPS" >&2 + exit 1 + fi + + echo "Stopping systemd service (if running)..." + sudo systemctl stop runewager.service || true + + echo "Killing any stray Node/bot processes..." + pkill -f "Runewager" || true + pkill -f "index.js" || true + pkill -f "node" || true + + echo "Installing production dependencies..." + npm ci --omit=dev + + echo "Starting systemd service cleanly..." + sudo systemctl daemon-reload || true + sudo systemctl start runewager.service + + echo "Checking systemd status..." + sudo systemctl is-active --quiet runewager.service + ' + + MSG="๐Ÿ”‘ *Deploy Phase โ€” SSH Key Attempt*"$'\n'"Host: \`${SERVER_HOST}\`" + ./scripts/notify-telegram.sh "$MSG" - # First attempt: SSH with key if ssh deploy_target "$DEPLOY_CMD"; then echo "โœ… Deployed via SSH key" + MSG="โœ… *Deploy Succeeded via SSH Key*"$'\n'"Host: \`${SERVER_HOST}\`" + ./scripts/notify-telegram.sh "$MSG" else - echo "โš ๏ธ SSH key attempt failed โ€” waiting 120 seconds before password retryโ€ฆ" + echo "โš ๏ธ SSH key failed โ€” waiting 120 seconds before password retry..." + MSG="โš ๏ธ *SSH Key Failed โ€” Waiting 2 Minutes Before Password Fallback*"$'\n'"Host: \`${SERVER_HOST}\`" + ./scripts/notify-telegram.sh "$MSG" 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" + MSG="โœ… *Deploy Succeeded via Password Fallback*"$'\n'"Host: \`${SERVER_HOST}\`" + ./scripts/notify-telegram.sh "$MSG" 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." + echo "โŒ Both SSH attempts failed" + MSG="โŒ *Deploy Failed โ€” SSH Key + Password Fallback Both Failed*"$'\n' + MSG+="Host: \`${SERVER_HOST}\`"$'\n' + MSG+="Action required: manual SSH + service check." + ./scripts/notify-telegram.sh "$MSG" exit 1 fi fi @@ -265,7 +323,7 @@ By: \`${{ github.actor }}\`" - name: Download deploy report from VPS if: always() run: | - ssh deploy_target "cat /tmp/deploy-report.txt 2>/dev/null || echo 'No deploy report file found on server.'" \ + ssh deploy_target "cat /tmp/deploy-report.txt 2>/dev/null || echo 'No deploy report file found.'" \ > /tmp/deploy-report.txt || echo 'No deploy report available.' > /tmp/deploy-report.txt - name: Upload deploy report as artifact @@ -277,16 +335,24 @@ By: \`${{ github.actor }}\`" if-no-files-found: ignore retention-days: 7 - - name: Notify success + - name: Post-deploy health check (30s delay) if: success() env: BOT_TOKEN: ${{ secrets.BOT_TOKEN }} run: | chmod +x scripts/notify-telegram.sh - ./scripts/notify-telegram.sh "โœ… *Deploy Successful* -Commit: \`${{ needs.quality-gates.outputs.commit_hash }}\` -Version: \`${{ needs.quality-gates.outputs.version }}\` -Time: \`${{ needs.quality-gates.outputs.deploy_time }}\`" + ./scripts/notify-telegram.sh "โณ *Post-Deploy Health Check in 30s*" + sleep 30 + + HEALTH_STATUS="FAIL" + if ssh deploy_target "curl -fsS http://127.0.0.1:3000/health" >/dev/null 2>&1; then + HEALTH_STATUS="OK" + fi + + MSG="๐Ÿ“ก *Post-Deploy Health Check*"$'\n' + MSG+="Status: \`${HEALTH_STATUS}\`"$'\n' + MSG+="Commit: \`${{ needs.quality-gates.outputs.commit_hash }}\`" + ./scripts/notify-telegram.sh "$MSG" - name: Rollback on failure if: failure() @@ -294,25 +360,31 @@ Time: \`${{ needs.quality-gates.outputs.deploy_time }}\`" BOT_TOKEN: ${{ secrets.BOT_TOKEN }} run: | PRE="${{ steps.pre.outputs.sha }}" - chmod +x scripts/notify-telegram.sh - ./scripts/notify-telegram.sh "โŒ *Deploy Failed โ€” Rolling Back* -Previous SHA: \`${PRE}\` -Reason: Deployment failed โ€” see GitHub Actions logs for full stack trace." + + MSG="โŒ *Deploy Failed โ€” Starting Rollback*"$'\n'"Previous SHA: \`${PRE}\`" + ./scripts/notify-telegram.sh "$MSG" ssh deploy_target " cd /var/www/html/Runewager if [ \"$PRE\" != \"none\" ] && [ -n \"$PRE\" ]; then - git reset --hard $PRE + git reset --hard \"$PRE\" npm ci --omit=dev || true - systemctl restart runewager.service || bash prod-run.sh restart || true + + sudo systemctl stop runewager.service || true + pkill -f \"Runewager\" || true + pkill -f \"index.js\" || true + pkill -f \"node\" || true + + sudo systemctl daemon-reload || true + sudo systemctl start runewager.service || true else echo 'No previous SHA recorded, skipping git rollback' fi " - ./scripts/notify-telegram.sh "๐Ÿ”„ *Rollback Complete* -Restored to: \`${PRE}\`" + MSG="๐Ÿ”„ *Rollback Complete*"$'\n'"Restored to: \`${PRE}\`" + ./scripts/notify-telegram.sh "$MSG" - name: Final deploy report if: always() @@ -323,50 +395,28 @@ Restored to: \`${PRE}\`" COMMIT="${{ needs.quality-gates.outputs.commit_hash }}" VERSION="${{ needs.quality-gates.outputs.version }}" TIME="${{ needs.quality-gates.outputs.deploy_time }}" - DURATION="${{ needs.quality-gates.outputs.duration_seconds }}" REPORT_BODY=$(cat /tmp/deploy-report.txt 2>/dev/null || echo "No deploy report available.") if [ "$STATUS" = "success" ]; then - MSG="๐ŸŽ‰ *Final Deploy Report* -Status: โœ… Success -Commit: \`$COMMIT\` -Version: \`$VERSION\` -Time: \`$TIME\` -Duration: \`${DURATION}s\` - -$REPORT_BODY" + MSG="๐ŸŽ‰ *Final Deploy Report*"$'\n' + MSG+="Status: โœ… Success"$'\n' + MSG+="Commit: \`$COMMIT\`"$'\n' + MSG+="Version: \`$VERSION\`"$'\n' + MSG+="Time: \`$TIME\`"$'\n\n' + MSG+="$REPORT_BODY" else - MSG="โš ๏ธ *Final Deploy Report* -Status: โŒ Failure -Commit: \`$COMMIT\` -Version: \`$VERSION\` -Time: \`$TIME\` -Duration: \`${DURATION}s\` -Reason: Deployment failed โ€” see GitHub Actions logs. - -$REPORT_BODY" + MSG="โš ๏ธ *Final Deploy Report*"$'\n' + MSG+="Status: โŒ Failure"$'\n' + MSG+="Commit: \`$COMMIT\`"$'\n' + MSG+="Version: \`$VERSION\`"$'\n' + MSG+="Time: \`$TIME\`"$'\n\n' + MSG+="$REPORT_BODY" fi chmod +x scripts/notify-telegram.sh ./scripts/notify-telegram.sh "$MSG" - - name: Post-deploy health check (30s delay) - if: success() - env: - BOT_TOKEN: ${{ secrets.BOT_TOKEN }} - run: | - echo "Waiting 30 seconds before post-deploy health check..." - sleep 30 - HEALTH_STATUS="FAIL" - if ssh deploy_target "curl -fsS http://127.0.0.1:3000/health" >/dev/null 2>&1; then - HEALTH_STATUS="OK" - fi - chmod +x scripts/notify-telegram.sh - ./scripts/notify-telegram.sh "๐Ÿ“ก *Post-Deploy Health Check (30s)* -Status: \`${HEALTH_STATUS}\` -Commit: \`${{ needs.quality-gates.outputs.commit_hash }}\`" - # --------------------------------------------------------------------------- # Job 3: Dry-run report (only when dry_run=true) # --------------------------------------------------------------------------- @@ -384,12 +434,12 @@ Commit: \`${{ needs.quality-gates.outputs.commit_hash }}\`" BOT_TOKEN: ${{ secrets.BOT_TOKEN }} run: | chmod +x scripts/notify-telegram.sh - ./scripts/notify-telegram.sh "๐Ÿงช *Dry Run Only โ€” No VPS Changes* -Repo: \`${{ github.repository }}\` -Branch: \`${{ github.ref_name }}\` -Commit: \`${{ needs.quality-gates.outputs.commit_hash }}\` -Version: \`${{ needs.quality-gates.outputs.version }}\` -Time: \`${{ needs.quality-gates.outputs.deploy_time }}\` -By: \`${{ github.actor }}\` - -Quality gates passed. VPS was NOT modified." + MSG="๐Ÿงช *Dry Run Only โ€” No VPS Changes*"$'\n' + MSG+="Repo: \`${{ github.repository }}\`"$'\n' + MSG+="Branch: \`${{ github.ref_name }}\`"$'\n' + MSG+="Commit: \`${{ needs.quality-gates.outputs.commit_hash }}\`"$'\n' + MSG+="Version: \`${{ needs.quality-gates.outputs.version }}\`"$'\n' + MSG+="Time: \`${{ needs.quality-gates.outputs.deploy_time }}\`"$'\n' + MSG+="By: \`${{ github.actor }}\`"$'\n\n' + MSG+="Quality gates passed. VPS was NOT modified." + ./scripts/notify-telegram.sh "$MSG" diff --git a/deploy.sh b/deploy.sh index f4a5b36..e18a27b 100755 --- a/deploy.sh +++ b/deploy.sh @@ -11,7 +11,7 @@ # vps โ€” triggered by a manual SSH/shell call on the VPS # # What it does: -# 0. Skip deploy if VPS already has the latest commit +# 0. Skip deploy if VPS already has the latest commit (and service is active) # 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) @@ -38,15 +38,36 @@ 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 +# Load BOT_TOKEN and ADMIN_IDS for Telegram notifications. +# When deploy.sh is spawned from the bot process (source=bot), +# these are already inherited via process.env โ€” don't overwrite. +# When invoked from GitHub Actions or a cron, parse from .env. +# +# _parse_env_value handles: +# - bare lines: KEY=value +# - exported lines: export KEY=value +# - inline comments (space-prefixed): KEY=value # comment +# - single- or double-quoted values: KEY='value' / KEY="value" +# - CRLF line endings +# --------------------------------------------------------- +_parse_env_value() { + local key="$1" file="$2" + grep -E "^(export[[:space:]]+)?${key}=" "$file" 2>/dev/null \ + | head -1 \ + | sed -E "s/^(export[[:space:]]+)?${key}=//" \ + | sed 's/[[:space:]]\{1,\}#.*$//' \ + | sed "s/^[\"']//" \ + | sed "s/[\"']$//" \ + | tr -d $'\r' +} + +if [[ -z "${BOT_TOKEN:-}" && -f "$PROJECT_DIR/.env" ]]; then + BOT_TOKEN="$(_parse_env_value BOT_TOKEN "$PROJECT_DIR/.env")" || true +fi +if [[ -z "${ADMIN_IDS:-}" && -f "$PROJECT_DIR/.env" ]]; then + ADMIN_IDS="$(_parse_env_value ADMIN_IDS "$PROJECT_DIR/.env")" || true fi +export BOT_TOKEN ADMIN_IDS # --------------------------------------------------------- # Silent Telegram admin notification helper @@ -72,14 +93,35 @@ send_admin() { } # --------------------------------------------------------- -# Error trap โ€” always notify admins if the script fails +# Track whether the service was stopped so the ERR trap can +# attempt a recovery restart if the deploy fails mid-way. +# --------------------------------------------------------- +_SERVICE_STOPPED=false + +# --------------------------------------------------------- +# Error trap โ€” notify admins and attempt service recovery. +# ${BASH_LINENO[0]} gives the caller's line number when +# evaluated inside an ERR trap (more reliable than $LINENO). # --------------------------------------------------------- _on_error() { - local line="$1" + # BASH_LINENO[0] is set by bash to the line of the failing command when + # this function is invoked via the ERR trap (bash 4.0+). + local line="${BASH_LINENO[0]:-?}" warn "Script failed at line $line" - send_admin "โŒ Deploy failed at line $line โ€” check VPS logs. (source: $DEPLOY_SOURCE)" + send_admin "โŒ Deploy FAILED at line $line โ€” check VPS logs. (source: $DEPLOY_SOURCE)" + # Defensive read: use parameter default in case a very early failure fires + # the trap before _SERVICE_STOPPED is initialised (impossible today, but + # safe for future re-ordering of the script). + # Only attempt recovery on systemd hosts where the service was stopped. + if [[ "${_SERVICE_STOPPED:-false}" == "true" ]] && command -v systemctl >/dev/null 2>&1; then + warn "Attempting service recovery restart on existing codeโ€ฆ" + systemctl start "${APP_NAME}.service" || true + local _recovery_status + _recovery_status="$(systemctl is-active "${APP_NAME}.service" 2>/dev/null || echo unknown)" + send_admin "๐Ÿ” Recovery restart attempted after deploy failure โ€” service is now: $_recovery_status" + fi } -trap '_on_error "$LINENO"' ERR +trap '_on_error' ERR # --------------------------------------------------------- # 0) Skip deploy if VPS already has the latest commit @@ -89,9 +131,18 @@ trap '_on_error "$LINENO"' ERR 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." - send_admin "โœ… Bot is already running the latest version (commit: $(git rev-parse --short HEAD))." - exit 0 + ACTIVE="unknown" + if command -v systemctl >/dev/null 2>&1; then + ACTIVE="$(systemctl is-active "${APP_NAME}.service" 2>/dev/null || echo unknown)" + fi + if [[ "$ACTIVE" == "active" ]]; 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 + else + warn "Code is up to date but service status is: $ACTIVE โ€” continuing deploy to (re)start service." + send_admin "โ„น๏ธ Code is up to date but service status is: $ACTIVE โ€” continuing deploy to (re)start service." + fi fi # Resolve human-readable source label for notifications @@ -110,6 +161,7 @@ if command -v systemctl >/dev/null 2>&1; then say "Step 1/4 โ€” Stopping ${APP_NAME} serviceโ€ฆ" send_admin "๐Ÿ›‘ Stopping bot serviceโ€ฆ" systemctl stop "${APP_NAME}.service" || true + _SERVICE_STOPPED=true fi # --------------------------------------------------------- @@ -138,21 +190,37 @@ else # 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 + _SERVICE_STOPPED=false fi exit 1 fi # --------------------------------------------------------- # 3) Install / update production dependencies +# Wrapped in a conditional identical to the git guard: +# if npm fails the service is left down without this guard. +# On failure we restart on the existing node_modules so +# the bot comes back up on whatever was last working. # --------------------------------------------------------- say "Step 3/4 โ€” Installing production dependenciesโ€ฆ" send_admin "๐Ÿ“ฆ Installing dependenciesโ€ฆ" -if [[ -f package-lock.json ]]; then - npm ci --omit=dev + +NPM_CMD="npm install --omit=dev" +[[ -f package-lock.json ]] && NPM_CMD="npm ci --omit=dev" + +NPM_OUT="" +if NPM_OUT=$(${NPM_CMD} 2>&1); then + say "Dependencies installed." else - npm install --omit=dev + warn "npm install failed โ€” restarting bot on existing node_modules" + warn "npm output: $NPM_OUT" + send_admin "โŒ npm install failed โ€” restarting bot on existing deps. Error: ${NPM_OUT:0:200} (source: $DEPLOY_SOURCE)" + if command -v systemctl >/dev/null 2>&1; then + systemctl start "${APP_NAME}.service" || true + _SERVICE_STOPPED=false + fi + exit 1 fi -say "Dependencies installed." # --------------------------------------------------------- # 4) Start bot via systemctl @@ -161,6 +229,7 @@ say "Step 4/4 โ€” Starting bot via systemctlโ€ฆ" send_admin "๐Ÿš€ Starting bot serviceโ€ฆ" if command -v systemctl >/dev/null 2>&1; then systemctl start "${APP_NAME}.service" + _SERVICE_STOPPED=false say "Bot started." else warn "systemctl not found. Start manually: node $PROJECT_DIR/index.js" diff --git a/index.js b/index.js index 037f648..8163e4c 100644 --- a/index.js +++ b/index.js @@ -101,11 +101,19 @@ async function sendIntroGif(ctx, caption, extra = {}) { const COPY = { intro: - '๐Ÿ‘‹ Welcome to Runewager! This platform uses SC (Sweepstakes Coins) for prize-based play and instant crypto prize redemption. No gambling language, no real-money wagering, and no deposit claims. Worldwide users are welcome.', + '๐Ÿ‘‹ Welcome to Runewager! This platform uses SC (Sweepstakes Coins) for prize-based play and instant crypto prize redemption. No gambling language, no real-money wagering, and no deposit claims. Runewager is 100% FREE to play and accepts players worldwide.', ageGate: - 'Before continuing, please confirm you are 18+ and eligible to participate in sweepstakes-style prize activities.', + '๐ŸŒ *Runewager is 100% free to play and accepts players worldwide.*\n\n' + + 'No real-money wagering. No deposits. No gambling. Just sweepstakes-style SC (Sweepstakes Coin) fun with instant crypto prize redemption.\n\n' + + 'Before continuing, please confirm you are 18+ and eligible to participate in sweepstakes-style prize activities in your region.', prizeExplanation: - 'Great! Here\'s how Runewager works:\nโ€ข SC = Sweepstakes Coins used for prize redemption\nโ€ข No real-money wagering\nโ€ข Prize redemption is instant crypto\nโ€ข New users can claim a 3.5 SC bonus using the promo code\nโ€ข Sign up under GambleCodez to unlock all rewards and giveaways', + 'Great! Here\'s how Runewager works:\n' + + 'โ€ข SC = Sweepstakes Coins used for prize redemption\n' + + 'โ€ข 100% free to play โ€” no deposits, no real-money wagering\n' + + 'โ€ข Accepts players worldwide ๐ŸŒ\n' + + 'โ€ข Prize redemption is instant crypto\n' + + 'โ€ข New users can claim a 3.5 SC bonus using the promo code\n' + + 'โ€ข Sign up under GambleCodez to unlock all rewards and giveaways', // Discord web-link step is shown here as an EXTERNAL link only. // Runewager's own Discord bot handles the actual code confirmation โ€” our Telegram bot does nothing. accountSetup: @@ -135,6 +143,7 @@ const promoStore = { bonusRule: process.env.PROMO_BONUS_RULE || DEFAULT_BONUS_RULE, claimsByUser: new Set(), logs: [], + cooldownDays: 0, // Feature 10: 0 = no cooldown; >0 = days between claims per user }; const giveawayStore = { @@ -184,7 +193,9 @@ const MAX_ANALYTICS_EVENTS = Number(process.env.MAX_ANALYTICS_EVENTS || 2000); const MAX_ONBOARDING_STEPS_HISTORY = Number(process.env.MAX_ONBOARDING_STEPS_HISTORY || 500); const promoHistoryStore = new Map(); const analyticsStore = { events: [], onboardingStarts: 0, onboardingCompletions: 0, webAppEvents: 0 }; -const referralStore = { boosts: [], links: new Map(), stats: new Map(), weekly: new Map() }; +const referralStore = { boosts: [], archivedBoosts: [], links: new Map(), stats: new Map(), weekly: new Map() }; +const approvedGroupsStore = new Set(); // Feature 8: set of chat_id numbers approved for giveaway posting +const broadcastFailedUsers = []; // Feature 5: [{userId, lastError, failedAt}] permanently failed users const smartButtonTracker = new Map(); // token -> expiry timestamp (TTL = 2 minutes) const userMutationQueue = new Map(); // per-user promise chain for atomic updates const processStartedAt = Date.now(); @@ -216,12 +227,30 @@ function runCmd(cmd, args, cwd, timeoutMs = 60000) { const walkthroughCatalog = buildWalkthroughCatalog(); const giveawayFeatureList = buildGiveawayFeatureList(); +const AFFILIATE_REMINDER_TEXT = + '๐Ÿ”” *Affiliate Reminder:* When signing up on Runewager, make sure you enter *GambleCodez* as your affiliate.\n' + + 'If you missed it, you can still continue โ€” just make sure your Runewager username is correct.'; + +// Words that are clearly not usernames โ€” used to filter accidental text during username input +const NON_USERNAME_WORDS = new Set([ + 'help', 'hi', 'hello', 'hey', 'back', 'cancel', 'stop', 'start', 'menu', + 'yes', 'no', 'ok', 'okay', 'done', 'next', 'skip', 'exit', 'quit', '?', 'thanks', + 'thank', 'please', 'go', 'home', 'main', 'more', 'info', 'test', 'bye', 'nope', +]); + const WAGER_BONUS_RULES_TEXT = - '๐ŸŽฏ 30 SC Wager Bonus โ€” Eligibility Requirements\n\n' - + 'โœ… You must be registered under the GambleCodez affiliate\n' - + 'โœ… You must have wagered 3,000 SC in any rolling 7-day period\n' - + 'โœ… This bonus is once per account (max 3 requests total)\n' - + 'โœ… Admins manually credit the 30 SC on Runewager โ€” the bot does not send it\n\n' + '๐ŸŽฏ *GambleCodez 30 SC Bonus โ€” Full Details*\n\n' + + `${AFFILIATE_REMINDER_TEXT}\n\n` + + '*Requirements:*\n' + + 'โœ… Must be under the *GambleCodez affiliate*\n' + + 'โœ… Must wager *3,000 SC* in any rolling 7-day period\n' + + 'โœ… Bonus is *once per account/IP*\n' + + 'โœ… You may submit *up to 3 verification attempts*\n\n' + + '*Process:*\n' + + 'โ€ข Bonus amount: *30 SC*\n' + + 'โ€ข Admin manually reviews and sends the bonus on Runewager\n' + + 'โ€ข Admin is pinged when you submit a verification request\n' + + 'โ€ข The bot does NOT send SC โ€” admin credits manually\n\n' + 'โš ๏ธ Do not message admins repeatedly. Requests are reviewed in order.'; const WAGER_BONUS_IN_FLIGHT_STATUSES = new Set(['pending', 'approved']); @@ -309,13 +338,18 @@ function createRuntimeStateSnapshot() { claimsByUser: Array.from(promoStore.claimsByUser || []), logs: promoStore.logs || [], bugreports: promoStore.bugreports || [], + cooldownDays: promoStore.cooldownDays || 0, }, referralStore: { boosts: referralStore.boosts || [], + archivedBoosts: referralStore.archivedBoosts || [], links: Array.from(referralStore.links.entries()), stats: Array.from(referralStore.stats.entries()), weekly: Array.from(referralStore.weekly.entries()), }, + approvedGroupsStore: Array.from(approvedGroupsStore), + broadcastFailedUsers, + promoStoreCooldownDays: promoStore.cooldownDays || 0, giveaways: { counter: giveawayStore.counter || 1, running: Array.from(giveawayStore.running.entries()).map(([id, g]) => ([id, serializeGiveaway(g)])), @@ -363,6 +397,8 @@ function serializeGiveaway(giveaway) { winners: Array.isArray(giveaway.winners) ? giveaway.winners : [], paidOut: Boolean(giveaway.paidOut), extendedCount: Number(giveaway.extendedCount) || 0, + paused: Boolean(giveaway.paused), // Feature 1: pause/resume control + winningSeed: giveaway.winningSeed || null, // Feature 7: {seed, pickedAt} for transparency }; } @@ -399,14 +435,25 @@ function loadRuntimeState() { promoStore.claimsByUser = new Set((raw.promoStore.claimsByUser || []).map((v) => Number(v))); promoStore.logs = Array.isArray(raw.promoStore.logs) ? raw.promoStore.logs : []; promoStore.bugreports = Array.isArray(raw.promoStore.bugreports) ? raw.promoStore.bugreports : []; + if (typeof raw.promoStore.cooldownDays === 'number') promoStore.cooldownDays = raw.promoStore.cooldownDays; } if (raw.referralStore) { referralStore.boosts = Array.isArray(raw.referralStore.boosts) ? raw.referralStore.boosts : []; + referralStore.archivedBoosts = Array.isArray(raw.referralStore.archivedBoosts) ? raw.referralStore.archivedBoosts : []; referralStore.links = new Map(raw.referralStore.links || []); referralStore.stats = new Map(raw.referralStore.stats || []); referralStore.weekly = new Map(raw.referralStore.weekly || []); } + if (Array.isArray(raw.approvedGroupsStore)) { + for (const id of raw.approvedGroupsStore) approvedGroupsStore.add(Number(id)); + } + if (Array.isArray(raw.broadcastFailedUsers)) { + broadcastFailedUsers.push(...raw.broadcastFailedUsers); + } + if (typeof raw.promoStoreCooldownDays === 'number') { + promoStore.cooldownDays = raw.promoStoreCooldownDays; + } if (raw.giveaways) { giveawayStore.counter = Number(raw.giveaways.counter) || giveawayStore.counter; @@ -515,6 +562,18 @@ function createDefaultUser(user) { wager7dayTotal: 0, // declared rolling 7-day SC wager total affiliateSource: 'GambleCodez', // all bot users come via GambleCodez affiliate }, + // โ”€โ”€ Feature 18: Daily streak tracking โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + streak: { count: 0, lastCheckInAt: 0, longestStreak: 0 }, + // โ”€โ”€ Feature 26: Weekly boost DM throttle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + lastWeeklyBoostDmAt: 0, + // โ”€โ”€ Feature 24: Telegram language code (detect once, use for UX hints) โ”€โ”€โ”€ + language: '', + // โ”€โ”€ Feature 2/17: Mark user as unreachable when DM is blocked โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + unreachable: false, + // โ”€โ”€ Feature 22: Lightweight giveaway join history โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + giveawayHistory: [], // [{id, joinedAt, won: false, wonSC: 0}] + // โ”€โ”€ Feature 25: Active structured support ticket โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + supportTicket: null, // null | {step:1-3, data:{}, startedAt} }; } @@ -574,6 +633,13 @@ function getUser(ctx) { if (!user.giveawayJoinedIds) user.giveawayJoinedIds = new Set(); if (!user.profileXP) user.profileXP = 0; if (!user.miniAppLastSyncAt) user.miniAppLastSyncAt = 0; + // Migrate new feature fields for existing users loaded from disk + if (!user.streak) user.streak = { count: 0, lastCheckInAt: 0, longestStreak: 0 }; + if (!user.lastWeeklyBoostDmAt) user.lastWeeklyBoostDmAt = 0; + if (!user.language && ctx.from.language_code) user.language = ctx.from.language_code; + if (user.unreachable === undefined) user.unreachable = false; + if (!user.giveawayHistory) user.giveawayHistory = []; + if (user.supportTicket === undefined) user.supportTicket = null; // Migrate: ensure settings block exists on older user records if (!user.settings) { user.settings = { playMode: 'miniapp', showQuickCommands: true, tooltipsEnabled: false }; @@ -671,14 +737,21 @@ async function submitBonusRequest(ctx, user) { }); const wb = user.wagerBonus; + const attemptsLeft = 3 - wb.attempts; await ctx.reply( - `โœ… Your request has been submitted. Admin will review and manually credit your 30 SC bonus on Runewager.\n\nAttempt ${wb.attempts}/3.`, + `โœ… Your request has been submitted!\n\n` + + `${AFFILIATE_REMINDER_TEXT}\n\n` + + `โ€ข You must wager 3,000 SC in any rolling 7-day period\n` + + `โ€ข Bonus is once per account/IP\n` + + `โ€ข Attempt ${wb.attempts}/3 used (${attemptsLeft} remaining)\n\n` + + `Admin has been notified and will manually review and credit your 30 SC bonus on Runewager.`, + { parse_mode: 'Markdown' }, ); const handle = user.tgUsername ? `@${user.tgUsername}` : `user${user.id}`; await notifyAdminsPlain( - `๐ŸŽฏ New 30 SC bonus request\n` - + `From: ${handle} (ID: ${user.id})\n` + `๐ŸŽฏ *New 30 SC bonus request โ€” admin action required*\n` + + `From: ${handle} (TG ID: ${user.id})\n` + `Runewager: ${user.runewagerUsername || '(not linked)'}\n` + `7-day wager declared: ${wb.wager7dayTotal} SC\n` + `Attempts: ${wb.attempts}/3\n` @@ -700,10 +773,13 @@ async function showBonusStatus(ctx, user) { bonus_sent: '๐ŸŽ‰ Bonus already received', }; const wagerDisplay = wb.wager7dayTotal > 0 ? `${wb.wager7dayTotal.toLocaleString()} SC` : '(not recorded)'; + const attemptsLeft = 3 - (wb.attempts || 0); const lines = [ '๐ŸŽฏ 30 SC Wager Bonus โ€” Your Status', '', - `Attempts used: ${wb.attempts}/3`, + AFFILIATE_REMINDER_TEXT, + '', + `Attempts used: ${wb.attempts}/3 (${attemptsLeft} remaining)`, `Status: ${statusLabels[wb.status] || wb.status}`, `Runewager username: ${user.runewagerUsername || '(not linked)'}`, `Affiliate: ${wb.affiliateSource || 'GambleCodez'}`, @@ -719,10 +795,11 @@ async function showBonusStatus(ctx, user) { lines.push('', 'This is a once-per-account bonus. No further requests are accepted.'); } const canRequest = wb.attempts < 3 && !isWagerBonusRequestLocked(wb.status); - const kb = canRequest - ? Markup.inlineKeyboard([[Markup.button.callback('๐ŸŽฏ Request Bonus', 'w30_request_start')]]) - : undefined; - await ctx.reply(lines.join('\n'), kb); + const kbRows = []; + if (canRequest) kbRows.push([Markup.button.callback('๐ŸŽฏ Request Bonus', 'w30_request_start')]); + kbRows.push([Markup.button.callback('โ„น๏ธ Tell me more about the 30 SC bonus', 'w30_bonus_info')]); + kbRows.push([Markup.button.callback('โฌ…๏ธ Main Menu', 'to_main_menu')]); + await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown', ...Markup.inlineKeyboard(kbRows) }); } function signupButton() { @@ -832,6 +909,7 @@ function mainMenuKeyboard(isAdminUser = false, page = 1, user = null) { } function ageGateKeyboard() { + // Note: parse_mode: 'Markdown' must be passed with this keyboard since COPY.ageGate uses Markdown return Markup.inlineKeyboard([ [Markup.button.callback('โœ… I am 18+', 'age_yes')], [Markup.button.callback('โŒ I am under 18', 'age_no')], @@ -865,21 +943,25 @@ function promoText() { function wager30AdminKeyboard() { return Markup.inlineKeyboard([ - [Markup.button.callback('๐Ÿ“‹ Pending Requests', 'w30_admin_pending')], - [Markup.button.callback('โœ… Approve Request', 'w30_admin_approve_pick')], - [Markup.button.callback('๐Ÿ“ค Mark Bonus Sent', 'w30_admin_sent_pick')], + [Markup.button.callback('๐Ÿ“‹ Open (Pending) Requests', 'w30_admin_pending')], + [Markup.button.callback('โœ… Completed Requests', 'w30_admin_completed')], + [Markup.button.callback('๐Ÿ‘ Approve Request', 'w30_admin_approve_pick')], [Markup.button.callback('โŒ Deny Request', 'w30_admin_deny_pick')], + [Markup.button.callback('๐Ÿ“ค Mark Tip Sent', 'w30_admin_sent_pick')], + [Markup.button.callback('๐Ÿ”— Add Username Manually', 'w30_admin_link_username')], [Markup.button.callback('โž• Add Manual Record (bonus_sent)', 'w30_admin_add_pick')], [Markup.button.callback('๐Ÿ” Lookup User', 'w30_admin_lookup')], [Markup.button.callback('๐Ÿ“Š Stats', 'w30_admin_stats')], [Markup.button.callback('โ™ป๏ธ Reset User', 'w30_admin_reset')], + [Markup.button.callback('โฌ…๏ธ Return to Admin Menu', 'open_admin_dashboard')], ]); } function wagerReminderKeyboard() { return Markup.inlineKeyboard([ [Markup.button.callback('๐ŸŽฏ Request My 30 SC Bonus', 'w30_request_start')], - [Markup.button.callback('โ„น๏ธ Bonus Rules', 'w30_rules')], + [Markup.button.callback('โ„น๏ธ Tell me more about the 30 SC bonus', 'w30_bonus_info')], + [Markup.button.callback('๐Ÿ“‹ Bonus Rules', 'w30_rules')], ]); } @@ -1137,11 +1219,12 @@ function userMainMenuText(user) { return ( `๐Ÿ‘‹ Hey ${name}! Here's your Runewager menu.\n\n` + (statusLine ? `${statusLine}\n\n` : '') + + `๐ŸŒ *Runewager is 100% FREE to play โ€” worldwide access, no deposits.*\n\n` + `๐ŸŽฎ *Play Now* โ€” Open the Runewager Mini App\n` - + `๐ŸŽ *Claim Bonus* โ€” New user SC bonus & 30 SC wager bonus\n` - + `๐Ÿ“Š *My Profile* โ€” View your linked account & stats\n` - + `๐Ÿ† *Giveaways* โ€” Active SC giveaways\n` - + `โ“ *Help / Commands* โ€” Full command reference` + + `๐ŸŽ *Claim Bonus* โ€” 3.5 SC new-user bonus & 30 SC wager bonus\n` + + `๐Ÿ“Š *My Profile* โ€” Linked account, XP, badges & referrals\n` + + `๐Ÿ† *Giveaways* โ€” Active SC giveaways & eligibility\n` + + `โ“ *Help / Commands* โ€” Command booklet & bug reports` ); } @@ -1178,6 +1261,8 @@ function adminMainMenuKeyboard() { [Markup.button.callback('๐ŸŽ‰ Start Giveaway', 'pamenu_start_giveaway')], [Markup.button.callback('๐ŸŽ Active Giveaways', 'pamenu_active_giveaways')], [Markup.button.callback('๐Ÿ›  Tools', 'pamenu_tools')], + [Markup.button.callback('๐Ÿ“– Admin Commands Reference', 'pamenu_admin_help')], + [Markup.button.callback('๐Ÿž Bug Reports', 'pamenu_bug_reports')], [Markup.button.callback('โ†ฉ Back to User Menu', 'pamenu_back_user')], ]); } @@ -1693,84 +1778,94 @@ function persistAnalytics() { } async function configureBotSurface() { + // โ”€โ”€ User commands visible in ALL chats (Telegram's global default) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const globalCommands = [ - { command: 'start', description: 'Start / resume onboarding' }, - { command: 'menu', description: 'Open main menu' }, - { command: 'help', description: 'Help and support' }, - { command: 'play', description: 'Launch Runewager Mini App' }, - { command: 'profile', description: 'View profile and badges' }, - { command: 'status', description: 'Quick status summary' }, - { command: 'promo', description: 'View current promo code and bonus' }, - { command: 'signup', description: 'Sign up link for Runewager' }, - { command: 'affiliate', description: 'Open affiliate / promo entry page' }, - { command: 'discord', description: 'Open Runewager Discord' }, - { command: 'referral', description: 'Get your referral link' }, - { command: 'walkthrough', description: 'Guided walkthrough (35 steps)' }, - { command: 'linkrunewager', description: 'Link your Runewager username' }, - { command: 'leaderboard', description: 'Bot activity leaderboard' }, - { command: 'bonus', description: 'View 30 SC bonus status / request bonus' }, - { command: 'bugreport', description: 'Report a bug or issue' }, - { command: 'commands', description: 'Help guide (alias for /help)' }, - { command: 'settings', description: 'Your preferences (play mode, tooltips, quick commands)' }, + { command: 'start', description: 'Start or resume onboarding' }, + { command: 'menu', description: 'Open your main menu' }, + { command: 'help', description: 'Help guide, commands & bug report' }, + { command: 'commands', description: 'Full command reference (alias for /help)' }, + { command: 'play', description: 'Launch Runewager Mini App inside Telegram' }, + { command: 'status', description: 'Quick account status โ€” 18+, username, promo, onboarding' }, + { command: 'profile', description: 'View your profile, XP, badges, referral code' }, + { command: 'linkrunewager', description: 'Link (or update) your Runewager username' }, + { command: 'bonus', description: 'View / request your 30 SC wager bonus' }, + { command: 'promo', description: 'View the current SC promo code (3.5 SC new-user bonus)' }, + { command: 'signup', description: 'Get the Runewager sign-up link under GambleCodez affiliate' }, + { command: 'affiliate', description: 'Open the affiliate / promo code entry page' }, + { command: 'discord', description: 'Runewager Discord join & code-link channel links' }, + { command: 'referral', description: 'Get your personal referral invite link' }, + { command: 'walkthrough', description: '35-step guided account setup tutorial' }, + { command: 'leaderboard', description: 'Referral leaderboard (top referrers)' }, + { command: 'leaderboard_weekly', description: 'Most active users in the last 7 days' }, + { command: 'giveaway', description: 'View active SC giveaways and eligibility' }, + { command: 'settings', description: 'Your preferences: play mode, tooltips, quick commands' }, + { command: 'bugreport', description: 'Report a bug or issue to the admin team' }, ]; + + // โ”€โ”€ Additional commands only shown in private DM chats โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const privateCommands = globalCommands.concat([ - { command: 'cancel', description: 'Cancel pending input' }, - { command: 'claim_history', description: 'View promo claim history' }, + { command: 'cancel', description: 'Cancel any pending input flow and go back to menu' }, + { command: 'claim_history', description: 'View your promo claim history' }, ]); - // Group commands: all commands users will want to type in a group chat. - // Telegram shows this list when a user types / in the group. + + // โ”€โ”€ Commands shown when user types / in a GROUP chat โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const groupCommands = [ + { command: 'start', description: 'Set up your account (DM flow)' }, { command: 'play', description: 'Launch Runewager Mini App' }, - { command: 'start', description: 'Start / link your account (opens in DM)' }, { command: 'signup', description: 'Sign up under GambleCodez affiliate' }, - { command: 'promo', description: 'View current promo code and bonus' }, - { command: 'leaderboard', description: 'Bot activity leaderboard' }, - { command: 'referral', description: 'Get your referral link' }, - { command: 'status', description: 'Quick account status' }, - { command: 'profile', description: 'View your profile' }, + { command: 'promo', description: 'View current promo code' }, { command: 'bonus', description: 'View 30 SC bonus status' }, { command: 'linkrunewager', description: 'Link your Runewager username' }, - { command: 'discord', description: 'Open Runewager Discord' }, - { command: 'help', description: 'Help and commands' }, + { command: 'status', description: 'Quick account status' }, + { command: 'profile', description: 'View your profile' }, + { command: 'leaderboard', description: 'Referral leaderboard' }, + { command: 'referral', description: 'Get your referral link' }, { command: 'giveaway', description: 'View / join active giveaways' }, - { command: 'affiliate', description: 'Open affiliate / promo entry page' }, + { command: 'discord', description: 'Runewager Discord links' }, + { command: 'affiliate', description: 'Affiliate / promo entry page' }, + { command: 'help', description: 'Help guide & commands' }, { command: 'bugreport', description: 'Report a bug or issue' }, ]; + await bot.telegram.setMyCommands(globalCommands, { scope: { type: 'default' } }).catch(() => {}); await bot.telegram.setMyCommands(privateCommands, { scope: { type: 'all_private_chats' } }).catch(() => {}); await bot.telegram.setMyCommands(groupCommands, { scope: { type: 'all_group_chats' } }).catch(() => {}); - // Sequential per-admin command registration โ€” order matters; disable lint rule + + // โ”€โ”€ Admin-specific commands (shown in their private DM only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const adminCommands = [ + ...privateCommands, + { command: 'admin', description: 'Open full admin dashboard' }, + { command: 'admin_help', description: 'Admin command reference guide (page 6 of help booklet)' }, + { command: 'admin_dashboard', description: 'Live stats: users, promo claims, giveaways, errors' }, + { command: 'wager30_admin', description: '30 SC bonus admin panel (approve/deny/sent/add/reset)' }, + { command: 'giveaway', description: 'Create / manage SC giveaways' }, + { command: 'start_giveaway', description: 'Start giveaway wizard (alias)' }, + { command: 'setpromo', description: 'Set or update the active promo display message' }, + { command: 'boost_referrals', description: 'Activate referral boost: /boost_referrals [hours] [multiplier]' }, + { command: 'whois', description: 'Look up any user by TG ID or Runewager username' }, + { command: 'bonusstatus', description: 'View full bonus state for a user' }, + { command: 'refreshuser', description: 'Force-migrate a user to the latest schema' }, + { command: 'bugreports', description: 'View all open bug reports' }, + { command: 'exportbugs', description: 'Export all open bug reports as plain text' }, + { command: 'resolvebug', description: 'Mark a bug report resolved: /resolvebug ' }, + { command: 'adminmode', description: 'Toggle admin bypass mode: /adminmode on|off' }, + { command: 'on', description: 'Enable admin bypass mode (test as a regular user)' }, + { command: 'off', description: 'Disable admin bypass mode (restore admin checks)' }, + { command: 'testall', description: 'Run full static self-test โ€” shows PASS/FAIL for every feature' }, + { command: 'testgiveaway', description: 'Run fake giveaway end-to-end test (no real SC)' }, + { command: 'health', description: 'Show live health endpoint data' }, + { command: 'version', description: 'Bot version, Node.js version, uptime' }, + { command: 'admin_backup', description: 'Snapshot runtime state to a dated backup file' }, + { command: 'deploy', description: 'Trigger a live deploy from the latest git commit' }, + { command: 'deploy_status', description: 'View last deploy result and timing' }, + { command: 'logs', description: 'Show last N lines of the bot log file' }, + { command: 'admin_notify', description: 'Send a direct admin notification message' }, + ]; + // eslint-disable-next-line no-restricted-syntax for (const adminId of ADMIN_IDS) { // eslint-disable-next-line no-await-in-loop - await bot.telegram.setMyCommands([ - ...privateCommands, - { command: 'admin', description: 'Admin dashboard (full)' }, - { command: 'admin_help', description: 'Admin help guide with all commands' }, - { command: 'admin_dashboard', description: 'Live user & promo dashboard' }, - { command: 'giveaway', description: 'Create / manage giveaways' }, - { command: 'start_giveaway', description: 'Start SC giveaway wizard (alias)' }, - { command: 'bonus', description: '30 SC bonus: pending / approve / deny / sent / add' }, - { command: 'wager30_admin', description: '30 SC bonus admin panel (alias)' }, - { command: 'admin_backup', description: 'Create runtime state backup snapshot' }, - { command: 'boost_referrals', description: 'Activate referral boost' }, - { command: 'setpromo', description: 'Set promo display message' }, - { command: 'bugreports', description: 'View open bug reports' }, - { command: 'on', description: 'Enable admin mode (bypass auth to test as user)' }, - { command: 'off', description: 'Disable admin mode (restore normal admin auth)' }, - { command: 'admin_mode_on', description: 'Enable admin bypass mode (alias for /on)' }, - { command: 'admin_mode_off', description: 'Disable admin bypass mode (alias for /off)' }, - { command: 'testall', description: 'Run full static self-test of all bot features (PASS/FAIL)' }, - { command: 'testgiveaway', description: 'Run fake giveaway quick-test with 10 fake participants' }, - { command: 'whois', description: 'Look up user by TG ID or Runewager username' }, - { command: 'bonusstatus', description: 'View bonus state for a user' }, - { command: 'refreshuser', description: 'Force-migrate user data to latest schema' }, - { command: 'adminmode', description: 'Toggle admin bypass mode: /adminmode on|off' }, - { command: 'health', description: 'Show live health endpoint data' }, - { command: 'version', description: 'Bot version, Node version, uptime' }, - { command: 'resolvebug', description: 'Mark a bug report as resolved' }, - { command: 'exportbugs', description: 'Dump all open bug reports as text' }, - ], { scope: { type: 'chat', chat_id: adminId } }).catch(() => {}); + await bot.telegram.setMyCommands(adminCommands, { scope: { type: 'chat', chat_id: adminId } }).catch(() => {}); } await bot.telegram.setChatMenuButton({ menu_button: { type: 'web_app', text: '๐ŸŽฎ Play', web_app: { url: LINKS.miniAppPlay } }, @@ -1854,10 +1949,12 @@ bot.start(safeStepHandler('start', async (ctx) => { + 'This bot helps you join Runewager under the GambleCodez affiliate, ' + 'claim exclusive SC (Sweepstakes Coin) bonuses, and participate in SC prize giveaways โ€” ' + 'all without leaving Telegram.\n\n' + + '๐ŸŒ *Runewager is 100% FREE to play and accepts players worldwide.*\n' + 'โ€ข SC = Sweepstakes Coins โ€” instant crypto prize redemption\n' - + 'โ€ข No real-money wagering ยท No gambling language\n' - + 'โ€ข Worldwide users welcome ยท 18+ only\n' + + 'โ€ข No real-money wagering ยท No deposits ยท No gambling language\n' + + 'โ€ข 18+ only โ€” confirmation required before onboarding\n' + 'โ€ข New users eligible for 3.5 SC sign-up bonus\n\n' + + `${AFFILIATE_REMINDER_TEXT}\n\n` + 'Join the community to get notified of live SC giveaways ๐Ÿ‘‡'; const introButtons = Markup.inlineKeyboard([ [ @@ -1868,9 +1965,10 @@ bot.start(safeStepHandler('start', async (ctx) => { Markup.button.callback('๐ŸŽ‰ SC Giveaways', 'menu_giveaways'), Markup.button.callback('โ“ Help Booklet', 'open_help'), ], + [Markup.button.callback('โ„น๏ธ Tell me more about the 30 SC bonus', 'w30_bonus_info')], ]); await sendIntroGif(ctx, introCaption, { parse_mode: 'Markdown', ...introButtons }); - await ctx.reply(COPY.ageGate, ageGateKeyboard()); + await ctx.reply(COPY.ageGate, { parse_mode: 'Markdown', ...ageGateKeyboard() }); return; } @@ -1891,11 +1989,10 @@ bot.start(safeStepHandler('start', async (ctx) => { + 'Runewager brings the heat with topโ€‘tier providers like Hacksaw, BGaming, Betsoft, ' + 'live games, and full sports action โ€” all wrapped inside a clean, sweepstakesโ€‘safe experience.\n\n' + 'We even run a massive weekly leaderboard where grinders climb for bragging rights and real prizes.\n\n' - + 'And the best part?\n' - + 'Runewager is 100% FREE to play.\n' - + 'No deposits. No risk.\n' - + 'Just instantโ€‘crypto prize redemptions and worldwide access for everyone.\n\n' - + 'Let\'s get you verified so we can unlock your bonuses and link your account.', + + '๐ŸŒ The best part? Runewager is 100% FREE to play and accepts players WORLDWIDE.\n' + + 'No deposits. No real-money wagering. No risk.\n' + + 'Just instantโ€‘crypto prize redemptions for everyone, everywhere.\n\n' + + 'Let\'s get you set up so we can unlock your bonuses and link your account.', ); const iChatId = introRunewagerMsg.chat.id; const iMsgId = introRunewagerMsg.message_id; @@ -1925,7 +2022,7 @@ bot.command('menu', async (ctx) => { const user = getUser(ctx); clearPendingAction(user); if (!user.ageConfirmed) { - await ctx.reply(COPY.ageGate, ageGateKeyboard()); + await ctx.reply(COPY.ageGate, { parse_mode: 'Markdown', ...ageGateKeyboard() }); return; } await sendPersistentUserMenu(ctx, user); @@ -1977,21 +2074,24 @@ function buildHelpPages(user) { '๐ŸŽฏ ABOUT THIS BOT', 'GambleCodez Runewager Bot helps you join Runewager under the GambleCodez affiliate and unlock exclusive SC (Sweepstakes Coin) rewards.', '', - 'โ€ข SC = Sweepstakes Coins โ€” prize redemption only', - 'โ€ข No real-money wagering ยท No gambling language', - 'โ€ข Worldwide users welcome ยท 18+ only', + '๐ŸŒ *Runewager is 100% FREE to play and accepts players worldwide.*', + 'โ€ข SC = Sweepstakes Coins โ€” prize redemption only, no real-money wagering', + 'โ€ข No deposits ยท No gambling language ยท 18+ only', + 'โ€ข Instant crypto prize redemption', 'โ€ข New users eligible for 3.5 SC sign-up bonus', '', '๐Ÿš€ QUICK START', - '1๏ธโƒฃ /start โ†’ Confirm 18+ โ†’ Create Runewager account', - '2๏ธโƒฃ Join Runewager Discord (for verification)', - '3๏ธโƒฃ /linkrunewager โ†’ Link your Runewager username', + '1๏ธโƒฃ /start โ†’ Confirm 18+ (age gate)', + '2๏ธโƒฃ Enter GambleCodez as your affiliate when signing up', + '3๏ธโƒฃ /linkrunewager โ†’ Link your exact Runewager username', '4๏ธโƒฃ /promo โ†’ Claim your 3.5 SC new user bonus', - '5๏ธโƒฃ Join our Channel & Group for SC giveaways', - tips ? '\nโ„น๏ธ Tip: Use /walkthrough for a full 35-step interactive tutorial.' : '', + '5๏ธโƒฃ /bonus โ†’ Request your 30 SC wager bonus when eligible', + '6๏ธโƒฃ Join our Channel & Group for live SC giveaways', + tips ? '\nโ„น๏ธ Tip: Use /walkthrough for a full 35-step interactive setup tutorial.' : '', ].filter(Boolean).join('\n'), buttons: [ [Markup.button.url('๐ŸŽฎ Play Now', LINKS.miniAppPlay), Markup.button.callback('๐Ÿ”— Link Account', 'menu_link_runewager')], + [Markup.button.callback('โ„น๏ธ 30 SC Bonus Info', 'w30_bonus_info')], navRow(1, total), [Markup.button.callback('โฌ…๏ธ Menu', 'to_main_menu')], ], @@ -2109,80 +2209,120 @@ function buildHelpPages(user) { ], }, - // โ”€โ”€ Page 5: Commands & Quick Reference โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // โ”€โ”€ Page 5: Full User Command Reference โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ { text: [ - '๐Ÿ“– *Help Guide โ€” Page 5/5: Commands & Quick Reference*', + '๐Ÿ“– *Help Guide โ€” Page 5/5: All Commands*', + '', + '๐Ÿ“‹ ACCOUNT & ONBOARDING', + '/start โ€” Start or resume onboarding (age gate โ†’ affiliate step โ†’ menu)', + '/menu โ€” Open your persistent main menu', + '/status โ€” See all flags: 18+ โœ…, username, promo, onboarding step', + '/profile โ€” View XP, badges, referral code, linked username', + '/settings โ€” Play mode (Mini App/Browser) ยท Tooltips ยท Quick Commands', + '/walkthrough โ€” 35-step guided tutorial covering account setup end-to-end', + '/cancel โ€” Cancel any pending input and return to the menu', '', - '๐Ÿ“‹ USER COMMANDS', - '/start โ€” Start or resume onboarding', - '/menu โ€” Open main menu', - '/help or /commands โ€” This help guide', - '/play โ€” Launch Runewager Mini App', - '/profile โ€” View profile & XP badges', - '/status โ€” Quick account status check', - '/bonus โ€” View or request your 30 SC bonus', - '/promo โ€” View current promo code', - '/linkrunewager โ€” Link your Runewager username', - '/signup โ€” Get sign-up link (GambleCodez affiliate)', - '/affiliate โ€” Open promo/affiliate entry page', - '/discord โ€” Runewager Discord links', - '/referral โ€” Your personal referral link', - '/walkthrough โ€” 35-step guided setup tutorial', - '/leaderboard โ€” Referral leaderboard (group chats)', - '/spin โ€” Daily bonus wheel (cosmetic rewards)', - '/settings โ€” Your preferences (play mode, tooltips, etc.)', - '/bugreport โ€” Report a bug or issue', - '/cancel โ€” Cancel any pending input flow', + '๐Ÿ”— RUNEWAGER ACCOUNT', + '/linkrunewager โ€” Link or update your exact Runewager username', + ' โ†ณ Required for: 30 SC bonus, SC giveaways, eligibility checks', + ' โ†ณ Always shown a confirmation step โ€” never auto-accepted', + '/signup โ€” Get the Runewager sign-up link with GambleCodez affiliate', + '/affiliate โ€” Open the promo / affiliate code entry page', + '/discord โ€” Runewager Discord server + code-link channel links', + '/play โ€” Launch Runewager Mini App directly inside Telegram', '', - '๐Ÿ”— QUICK LINKS', - `โ€ข Play: t.me/RuneWager_bot/Play`, - `โ€ข Sign Up: runewager.com/r/GambleCodez`, - `โ€ข Promo Entry: runewager.com/affiliate`, - `โ€ข Channel: t.me/GambleCodezDrops`, - `โ€ข Group: t.me/GambleCodezPrizeHub`, - `โ€ข Discord: discord.gg/runewagers`, + '๐ŸŽ BONUSES & PROMOS', + '/promo โ€” View current promo code (3.5 SC new-user bonus)', + '/bonus โ€” Check your 30 SC wager bonus status or submit a request', + ' โ†ณ Must wager 3,000 SC in any rolling 7-day period', + ' โ†ณ Must be under GambleCodez affiliate ยท once per account/IP', + ' โ†ณ Up to 3 verification attempts per account', + '/claim_history โ€” View your promo claim history', + '', + '๐Ÿ† COMMUNITY & SOCIAL', + '/giveaway โ€” View active SC giveaways and check your eligibility', + '/referral โ€” Get your personal referral invite link', + '/leaderboard โ€” Top referrers (all time)', + '/leaderboard_weekly โ€” Most active bot users in the last 7 days', + '', + 'โ“ HELP', + '/help or /commands โ€” Open this help booklet', + '/bugreport โ€” Report a bug or issue to the admin team', + '', + tips ? 'โ„น๏ธ Tip: Enable tooltips in /settings for extra guidance in each section.' : '', '', '๐Ÿ’ก PRO TIPS', - 'โ€ข Always sign up under GambleCodez โ€” unlocks all rewards', + 'โ€ข Always enter GambleCodez as affiliate when signing up on Runewager', 'โ€ข Link your username first โ€” required for bonuses & giveaways', - 'โ€ข Enable tooltips in /settings for extra guidance here', - ].join('\n'), + 'โ€ข Runewager is 100% free to play โ€” no deposits, worldwide access ๐ŸŒ', + ].filter(Boolean).join('\n'), buttons: [ - [Markup.button.url('๐ŸŽฎ Play Now', LINKS.miniAppPlay), Markup.button.url('๐Ÿ’ฌ Discord', LINKS.rwDiscordJoin)], + [Markup.button.url('๐ŸŽฎ Play Now', LINKS.miniAppPlay), Markup.button.callback('๐Ÿ”— Link Username', 'menu_link_runewager')], + [Markup.button.callback('๐ŸŽฏ 30 SC Bonus', 'w30_request_start'), Markup.button.url('๐Ÿ’ฌ Discord', LINKS.rwDiscordJoin)], + [Markup.button.callback('๐Ÿž Report a Bug', 'help_open_bugreport')], navRow(5, total), [Markup.button.callback('โฌ…๏ธ Menu', 'to_main_menu')], ], }, ]; - // โ”€โ”€ Page 6: Admin Tools (admins only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // โ”€โ”€ Page 6: Admin Command Reference (admins only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if (adminUser) { pages.push({ text: [ - '๐Ÿ“– *Help Guide โ€” Page 6/6: Admin Tools*', + '๐Ÿ“– *Help Guide โ€” Page 6/6: Admin Commands*', + '', + '๐Ÿ›  DASHBOARD & NAVIGATION', + '/admin โ€” Open the persistent admin main menu', + '/admin_help โ€” Open this admin command reference', + '/admin_dashboard โ€” Live stats: users, promos, giveaways, errors', + '', + '๐ŸŽฏ 30 SC BONUS MANAGEMENT', + '/wager30_admin โ€” Full bonus admin panel (keyboard-driven)', + '/bonus pending โ€” List all pending bonus requests', + '/bonus approve โ€” Approve a user\'s bonus request', + '/bonus deny [reason] โ€” Deny a bonus request with optional reason', + '/bonus sent โ€” Mark bonus as paid/sent (after manual Runewager credit)', + '/bonus add โ€” Manually add a bonus_sent record for legacy users', + '/bonusstatus โ€” View a user\'s full bonus state and history', '', - '๐Ÿ›  ADMIN DASHBOARD', - '/admin_dashboard โ€” or tap "๐Ÿ›  Admin Dashboard" in menu', - 'Categories: Giveaway ยท Promo ยท User ยท System ยท Support', + '๐ŸŽ‰ GIVEAWAY MANAGEMENT', + '/giveaway โ€” Start giveaway wizard (in group: wizard for that chat)', + '/start_giveaway โ€” Alias for /giveaway wizard', '', - '๐Ÿ“‹ QUICK REFERENCE', - '/testall โ€” Full diagnostic. Run after every deploy.', - '/testgiveaway โ€” Fake giveaway end-to-end test (no real SC).', - '/whois โ€” Look up any user by TG ID or Runewager username.', - '/bonusstatus โ€” View a user\'s full bonus state.', - '/refreshuser โ€” Force-migrate user to latest schema.', - '/health โ€” Show live health endpoint data.', - '/version โ€” Bot version, Node version, uptime.', - '/adminmode on|off โ€” Toggle admin bypass mode.', - '/exportbugs โ€” Dump all open bug reports as text.', - '/resolvebug โ€” Mark a bug report as resolved.', + '๐Ÿ“ข ANNOUNCEMENTS & PROMOS', + '/A or /announce โ€” Compose and broadcast an announcement', + '/setpromo โ€” Update the active promo display message', + '/boost_referrals [hours] [multiplier] โ€” Activate referral 2ร— boost', + '/admin_notify โ€” Send a targeted admin notification', '', - 'โ„น๏ธ All admin commands include smart error messages.', - 'โ„น๏ธ Dashboard has 2 pages with sub-category menus.', + '๐Ÿ‘ค USER MANAGEMENT', + '/whois โ€” Look up any user by TG ID or Runewager username', + '/refreshuser โ€” Force-migrate a user object to the latest schema', + '/adminmode on|off โ€” Toggle admin bypass mode (test as regular user)', + '/on โ€” Enable bypass mode ยท /off โ€” Disable bypass mode', + '', + '๐Ÿž BUG REPORTS', + '/bugreports โ€” View all open bug reports', + '/exportbugs โ€” Export all open bug reports as plain text', + '/resolvebug โ€” Mark a bug report as resolved', + '', + '๐Ÿ”ง SYSTEM', + '/testall โ€” Run full static self-test โ€” every feature PASS/FAIL', + '/testgiveaway โ€” Fake giveaway end-to-end test (no real SC)', + '/health โ€” Live health endpoint data', + '/version โ€” Bot version, Node.js version, uptime', + '/deploy โ€” Trigger live deploy from latest git commit', + '/deploy_status โ€” View last deploy result and timing', + '/logs [lines] โ€” Show last N lines of the bot log', + '/admin_backup โ€” Snapshot runtime state to a dated backup file', + '', + 'โ„น๏ธ All admin commands include smart error messages and usage hints.', ].join('\n'), buttons: [ [Markup.button.callback('๐Ÿ›  Open Admin Dashboard', 'open_admin_dashboard'), Markup.button.callback('๐Ÿงช Run TestAll', 'admin_cmd_testall')], + [Markup.button.callback('๐Ÿž Bug Reports', 'pamenu_bug_reports')], navRow(6, total), [Markup.button.callback('โฌ…๏ธ Menu', 'to_main_menu')], ], @@ -2223,14 +2363,15 @@ bot.command('linkrunewager', async (ctx) => { async function sendLinkAccountPrompt(ctx, user) { const alreadyLinked = user.runewagerUsername && user.runewagerUsername.trim(); const header = alreadyLinked - ? `๐Ÿ”— Your Runewager account is currently linked as: *${user.runewagerUsername}*\n\nSend a new username to update it, or tap Cancel to keep the current one.` - : '๐Ÿ”— *Link Your Runewager Account*\n\nTo request the 30 SC bonus and join SC giveaways you must link your Runewager username.\n\n*Steps:*\n1๏ธโƒฃ Sign up on Runewager (link below) if you haven\'t already\n2๏ธโƒฃ Find your exact username in your Runewager profile\n3๏ธโƒฃ Send it here โ€” exactly as it appears on Runewager'; + ? `๐Ÿ”— Your Runewager account is currently linked as: *${user.runewagerUsername}*\n\nSend a new username to update it, or tap Cancel to keep the current one.\n\n${AFFILIATE_REMINDER_TEXT}` + : `๐Ÿ”— *Link Your Runewager Account*\n\n${AFFILIATE_REMINDER_TEXT}\n\n*Steps:*\n1๏ธโƒฃ Sign up on Runewager (link below) if you haven't already\n2๏ธโƒฃ Find your exact username in your Runewager profile\n3๏ธโƒฃ Send it here โ€” exactly as it appears on Runewager\n\nโš ๏ธ Your message will not be accepted until you confirm it is correct.`; user.pendingAction = { type: 'await_runewager_username' }; await ctx.reply(header, { parse_mode: 'Markdown', ...Markup.inlineKeyboard([ [Markup.button.url('๐Ÿ”— Sign Up on Runewager', LINKS.runewagerSignup)], [Markup.button.url('๐Ÿ‘ค View My Runewager Profile', LINKS.runewagerProfile)], + [Markup.button.callback('โ„น๏ธ Tell me more about the 30 SC bonus', 'w30_bonus_info')], [Markup.button.callback('โŒ Cancel', 'cancel_link')], ]), }); @@ -2522,11 +2663,17 @@ bot.command('deploy', async (ctx) => { // Spawn deploy.sh detached โ€” it stops + restarts the service, // sends per-step Telegram notifications via curl, and handles errors. + // Pass the bot's full environment so deploy.sh inherits BOT_TOKEN and + // ADMIN_IDS and its send_admin() calls actually reach Telegram. const deployScript = path.join(PROJECT_DIR, 'deploy.sh'); const deployProc = spawn('bash', [deployScript, 'bot'], { detached: true, stdio: 'ignore', cwd: PROJECT_DIR, + env: { + ...process.env, + PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }, }); deployProc.unref(); @@ -3153,11 +3300,43 @@ bot.action('pmenu_giveaways', async (ctx) => { }); 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( + 'โ“ *Help & Support*\n\nChoose what you need:', + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('๐Ÿ“– Command Help Booklet', 'help_open_booklet')], + [Markup.button.callback('๐Ÿž Report a Bug', 'help_open_bugreport')], + [Markup.button.callback('โ„น๏ธ Tell me more about the 30 SC bonus', 'w30_bonus_info')], + [Markup.button.callback('โฌ…๏ธ Main Menu', 'to_main_menu')], + ]), + }, + ); +}); + +bot.action('help_open_booklet', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); await sendHelpMenu(ctx, user, 1); }); +bot.action('help_open_bugreport', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + clearPendingAction(user); + user.pendingAction = { type: 'await_bugreport' }; + await ctx.reply( + '๐Ÿž *Report a Bug*\n\nDescribe the issue as clearly as possible.\nInclude what you were doing and what went wrong.\n\nType your report now:', + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([[Markup.button.callback('โŒ Cancel', 'to_main_menu')]]), + }, + ); +}); + bot.action('pmenu_admin', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); @@ -3258,6 +3437,34 @@ bot.action('pamenu_tools', async (ctx) => { ); }); +bot.action('pamenu_admin_help', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + await sendHelpMenu(ctx, user, 6); +}); + +bot.action('pamenu_bug_reports', async (ctx) => { + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + const reports = (promoStore.bugreports || []).filter((r) => r.status === 'open'); + if (!reports.length) { + await ctx.reply('โœ… No open bug reports.', Markup.inlineKeyboard([[Markup.button.callback('โ†ฉ Admin Menu', 'pamenu_back_admin')]])); + return; + } + const lines = ['๐Ÿž *Open Bug Reports*\n']; + reports.slice(0, 20).forEach((r) => { + lines.push(`#${r.id} โ€” @${r.tgUsername || r.userId} โ€” ${new Date(r.timestamp).toUTCString().slice(0, 16)}\n${r.message.slice(0, 120)}`); + }); + if (reports.length > 20) lines.push(`โ€ฆand ${reports.length - 20} more. Use /exportbugs to see all.`); + await ctx.reply(lines.join('\n\n'), { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('โ†ฉ Admin Menu', 'pamenu_back_admin')], + ]), + }); +}); + bot.action('pamenu_back_user', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); @@ -3458,12 +3665,15 @@ bot.action('onboard_skip_to_link', async (ctx) => { user.verified = true; await ctx.answerCbQuery('Skipping to account link.'); await ctx.reply( - 'โœ… Got it! Let\'s link your existing Runewager account.\n\n' - + 'Please send your exact Runewager username so the bot can track your wager bonus and giveaway eligibility.', - Markup.inlineKeyboard([ - [Markup.button.url('๐Ÿ‘ค Find My Username on Runewager', LINKS.runewagerProfile)], - [Markup.button.callback('โŒ Cancel', 'cancel_link')], - ]), + `โœ… Got it! Let's link your existing Runewager account.\n\n${AFFILIATE_REMINDER_TEXT}\n\nPlease send your exact Runewager username so the bot can track your wager bonus and giveaway eligibility.\n\nโš ๏ธ You will be asked to confirm your username before it is saved.`, + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.url('๐Ÿ‘ค Find My Username on Runewager', LINKS.runewagerProfile)], + [Markup.button.callback('โ„น๏ธ Tell me more about the 30 SC bonus', 'w30_bonus_info')], + [Markup.button.callback('โŒ Cancel', 'cancel_link')], + ]), + }, ); user.pendingAction = { type: 'await_runewager_username' }; trackOnboardingProgress(user); @@ -3478,7 +3688,7 @@ bot.action('menu_verify_account', async (ctx) => { const user = getUser(ctx); clearPendingAction(user); if (!user.ageConfirmed) { - await ctx.reply(COPY.ageGate, ageGateKeyboard()); + await ctx.reply(COPY.ageGate, { parse_mode: 'Markdown', ...ageGateKeyboard() }); return; } await ctx.answerCbQuery(); @@ -3540,38 +3750,90 @@ 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; +// Shared handler: finalise username link after user confirmation +async function finaliseUsernameLink(ctx, user, normalized, returnTo) { const wasLinked = Boolean(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, '๐Ÿ”—'); + + // If triggered mid-flow (e.g. bonus request), resume that flow + if (returnTo === 'w30_bonus') { + await ctx.reply(`โœ… Username linked: *${normalized}*\n\n${AFFILIATE_REMINDER_TEXT}\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( - `โœ… 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}`, + `โœ… Runewager account linked!\n\n๐Ÿ‘ค Username: *${normalized}*\n\n${AFFILIATE_REMINDER_TEXT}\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('โ„น๏ธ Tell me more about the 30 SC bonus', 'w30_bonus_info')], [Markup.button.callback('๐Ÿ‘ค View Profile', 'menu_profile_action')], [Markup.button.callback('โฌ…๏ธ Main Menu', 'to_main_menu')], ]), }, ); +} + +// "Yes, Continue" โ€” user confirms the displayed username is correct +bot.action('confirm_yes_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; + } + await ctx.answerCbQuery('Username confirmed!'); + await finaliseUsernameLink(ctx, user, action.data.username, action.data.returnTo || null); +}); + +// "No, Edit" โ€” return user to WAITING_FOR_USERNAME +bot.action('confirm_no_username', async (ctx) => { + const user = getUser(ctx); + const action = user.pendingAction; + const returnTo = (action && action.data && action.data.returnTo) || null; + user.pendingAction = { type: 'await_runewager_username', returnTo }; + await ctx.answerCbQuery('OK, let\'s try again.'); + await ctx.reply( + 'Please type your exact Runewager username as it appears on Runewager:', + Markup.inlineKeyboard([ + [Markup.button.url('๐Ÿ‘ค Find My Username', LINKS.runewagerProfile)], + [Markup.button.callback('โŒ Cancel', 'cancel_link')], + ]), + ); +}); + +// Legacy alias kept for backward compat with any existing keyboard buttons +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; + } + await ctx.answerCbQuery('Username linked!'); + await finaliseUsernameLink(ctx, user, action.data.username, action.data.returnTo || null); }); bot.action('menu_join_channel', async (ctx) => { @@ -3601,7 +3863,7 @@ bot.action('menu_claim_bonus', async (ctx) => { clearPendingAction(user); await ctx.answerCbQuery(); if (!user.ageConfirmed) { - await ctx.reply(COPY.ageGate, ageGateKeyboard()); + await ctx.reply(COPY.ageGate, { parse_mode: 'Markdown', ...ageGateKeyboard() }); return; } if (needsLinkedUsername(user)) { @@ -3618,12 +3880,16 @@ bot.action('menu_claim_bonus', async (ctx) => { } await ctx.reply( - `${promoText()}\n\nSteps:\n1) Sign up on Runewager under GambleCodez\n2) Open the affiliate/promo page\n3) Enter the promo code shown above`, - Markup.inlineKeyboard([ - [signupButton()], - [promoButton()], - [Markup.button.callback('โœ… I Entered Promo Code', 'promo_confirm_claimed')], - ]), + `${promoText()}\n\n${AFFILIATE_REMINDER_TEXT}\n\nSteps:\n1) Sign up on Runewager under GambleCodez\n2) Open the affiliate/promo page\n3) Enter the promo code shown above`, + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [signupButton()], + [promoButton()], + [Markup.button.callback('โ„น๏ธ Tell me more about the 30 SC bonus', 'w30_bonus_info')], + [Markup.button.callback('โœ… I Entered Promo Code', 'promo_confirm_claimed')], + ]), + }, ); // Show promo entry screenshot so users know exactly where to type the code await sendPhoto(ctx, IMG_PROMO_ENTRY, '๐Ÿ“ธ Step 3: Enter the promo code here (Menu โ†’ Refer a Friend โ†’ Promo entry box)'); @@ -3664,7 +3930,48 @@ bot.action('promo_confirm_claimed', async (ctx) => { bot.action('w30_rules', async (ctx) => { await ctx.answerCbQuery(); - await ctx.reply(WAGER_BONUS_RULES_TEXT, Markup.inlineKeyboard([[Markup.button.callback('๐ŸŽฏ Request My 30 SC Bonus', 'w30_request_start')]])); + await ctx.reply(WAGER_BONUS_RULES_TEXT, { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('๐ŸŽฏ Request My 30 SC Bonus', 'w30_request_start')], + [Markup.button.callback('โฌ…๏ธ Back', 'to_main_menu')], + ]), + }); +}); + +// "Tell me more about the 30 SC bonus" โ€” full explanation with affiliate reminder +bot.action('w30_bonus_info', async (ctx) => { + await ctx.answerCbQuery(); + const user = getUser(ctx); + const wb = user.wagerBonus || {}; + const attemptsLeft = 3 - (wb.attempts || 0); + const infoText = [ + '๐ŸŽฏ *GambleCodez 30 SC Bonus โ€” Full Information*', + '', + AFFILIATE_REMINDER_TEXT, + '', + '*Requirements:*', + 'โ€ข Must be under the *GambleCodez* affiliate', + 'โ€ข Must wager *3,000 SC* in any rolling 7-day period', + 'โ€ข Bonus is *once per account/IP*', + `โ€ข You may submit *up to 3 verification attempts* (you have *${attemptsLeft}* remaining)`, + '', + '*Process:*', + 'โ€ข Bonus: *30 SC*', + 'โ€ข Admin manually reviews and approves', + 'โ€ข Admin is pinged the moment you request verification', + 'โ€ข Admin sends the bonus directly on Runewager โ€” the bot does NOT send SC', + '', + 'โš ๏ธ Do not message admins repeatedly. Requests are reviewed in order.', + ].join('\n'); + await ctx.reply(infoText, { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('๐ŸŽฏ Request My 30 SC Bonus', 'w30_request_start')], + [Markup.button.callback('๐Ÿ”— Link Runewager Username', 'menu_link_runewager')], + [Markup.button.callback('โฌ…๏ธ Back to Menu', 'to_main_menu')], + ]), + }); }); bot.action('w30_request_start', async (ctx) => { @@ -3687,11 +3994,14 @@ bot.action('w30_request_start', async (ctx) => { 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.', + + `${AFFILIATE_REMINDER_TEXT}\n\n` + + 'Send your exact Runewager username and we\'ll pick up your bonus request right after.\n\n' + + 'โš ๏ธ You will be asked to confirm your username before it is saved.', { parse_mode: 'Markdown', ...Markup.inlineKeyboard([ [Markup.button.url('๐Ÿ‘ค Find My Username', LINKS.runewagerProfile)], + [Markup.button.callback('โ„น๏ธ Tell me more about the 30 SC bonus', 'w30_bonus_info')], [Markup.button.callback('โŒ Cancel', 'cancel_link')], ]), }, @@ -4402,6 +4712,44 @@ bot.action('w30_admin_reset', async (ctx) => { await ctx.reply('Enter Telegram user ID or Runewager username to reset (returns to none, allows new request):'); }); +// View all completed bonus requests (approved + bonus_sent) +bot.action('w30_admin_completed', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + const completed = Array.from(userStore.values()).filter( + (u) => u.wagerBonus && (u.wagerBonus.status === 'bonus_sent' || u.wagerBonus.status === 'approved'), + ); + if (completed.length === 0) { + await ctx.reply('โœ… No completed requests yet.', Markup.inlineKeyboard([[Markup.button.callback('โฌ…๏ธ Back', 'open_admin_dashboard')]])); + return; + } + const lines = ['โœ… *Completed Bonus Requests*\n']; + for (const u of completed) { + const wb = u.wagerBonus; + const name = u.tgUsername ? `@${u.tgUsername}` : `user${u.id}`; + lines.push(`${name} (${u.id}) โ€” ${u.runewagerUsername || 'no username'} โ€” *${wb.status}* โ€” ${wb.attempts}/3 attempts`); + } + await ctx.reply(lines.join('\n'), { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([[Markup.button.callback('โฌ…๏ธ Back to Bonus Panel', 'open_admin_dashboard')]]), + }); +}); + +// Add Runewager username manually (for users who already had an account) +bot.action('w30_admin_link_username', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + clearPendingAction(user); + user.pendingAction = { type: 'w30_admin_link_username' }; + await ctx.answerCbQuery(); + await ctx.reply( + '๐Ÿ”— *Add Runewager Username Manually*\n\n' + + 'Enter: ` `\n' + + 'Example: `6668510825 johnplayer`', + { parse_mode: 'Markdown' }, + ); +}); + // Inline approve button from pending list bot.action(/^w30_admin_approve_user_(\d+)$/, async (ctx) => { if (!requireAdmin(ctx)) return; @@ -4461,6 +4809,8 @@ bot.action(/^gw_join_(\d+)$/, async (ctx) => { await ctx.answerCbQuery(); if (checks.missingLink) return linkedUsernameError(ctx); await ctx.reply(checks.message, checks.keyboard || undefined); + // Feature 17: auto-DM the exact missing step if user is in a group chat + if (ctx.chat && ctx.chat.type !== 'private') dmEligibilityFix(user, checks).catch(() => {}); return; } @@ -4478,6 +4828,10 @@ bot.action(/^gw_join_(\d+)$/, async (ctx) => { hasJoinedGroup: user.hasJoinedGroup, }); user.giveawayJoinedIds.add(gwId); + // Feature 22: record in personal giveaway history + if (!user.giveawayHistory) user.giveawayHistory = []; + user.giveawayHistory.push({ id: gwId, joinedAt: Date.now(), won: false, wonSC: 0 }); + if (user.giveawayHistory.length > 100) user.giveawayHistory.shift(); await ctx.answerCbQuery('Joined'); await ctx.reply("You're in! Stay tuned for the results after the countdown."); @@ -4682,27 +5036,45 @@ bot.on('text', async (ctx) => { const text = (ctx.message.text || '').trim(); + // โ”€โ”€ Feature 25: Support portal โ€” capture step-2 description โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (user.supportTicket && user.supportTicket.step === 2 && ctx.chat.type === 'private') { + const { issueType } = user.supportTicket.data; + user.supportTicket = null; + const ticketMsg = `๐Ÿ“ฉ *New Support Ticket*\n\nUser: ${user.tgUsername ? `@${user.tgUsername}` : `id:${user.id}`} (${user.id})\nIssue: ${issueType}\n\nDescription:\n${text.slice(0, 500)}`; + await notifyAdminsPlain(ticketMsg); + await ctx.reply( + 'โœ… *Support ticket submitted!*\n\nAn admin will review your issue shortly.', + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.url('๐ŸŽฎ Discord Support', process.env.RW_DISCORD_SUPPORT || 'https://discord.gg/runewagers')], + [Markup.button.callback('๐Ÿ  Main Menu', 'to_main_menu')], + ]), + }, + ); + return; + } + // 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. + // and the user has not yet linked an account. Always requires confirmation โ€” never auto-accepts. if (!user.pendingAction) { if ( text && !text.startsWith('/') && /^[A-Za-z0-9_.-]{3,30}$/.test(text) && - !user.runewagerUsername + !user.runewagerUsername && + !NON_USERNAME_WORDS.has(text.toLowerCase()) ) { const normalized = normalizeRunewagerUsername(text); - user.pendingAction = { type: 'await_username_confirm', data: { username: normalized } }; + user.pendingAction = { type: 'await_username_confirm', data: { username: normalized, returnTo: null } }; await ctx.reply( - `๐Ÿ‘ค Is *${normalized}* your Runewager username?`, + `๐Ÿ‘ค You entered: *${normalized}*\n\nIs this your exact 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')], + [Markup.button.callback('โœ… Yes, Continue', 'confirm_yes_username')], + [Markup.button.callback('โœ๏ธ No, Edit', 'confirm_no_username')], ]), }, ); @@ -4812,50 +5184,38 @@ bot.on('text', async (ctx) => { return; } - // User linking flow + // User linking flow โ€” NEVER auto-accept; always require explicit confirmation if (action.type === 'await_runewager_username') { if (!text) { - await ctx.reply('Username cannot be empty. Please send your exact Runewager username.'); + await ctx.reply('Username cannot be empty. Please type your exact Runewager username as it appears on Runewager.'); return; } - const normalizedUsername = normalizeRunewagerUsername(text); - const wasLinked = Boolean(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); - } + // Reject obvious non-username inputs + const lowerText = text.toLowerCase(); + if ( + NON_USERNAME_WORDS.has(lowerText) || + text.includes(' ') || + text.startsWith('/') || + text.length < 2 + ) { + await ctx.reply( + "โš ๏ธ That doesn't look like a Runewager username.\nPlease type your exact username as it appears on Runewager.", + Markup.inlineKeyboard([ + [Markup.button.callback('โŒ Cancel', 'cancel_link')], + ]), + ); 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.'; + // Stage for confirmation โ€” never auto-accept + const normalizedUsername = normalizeRunewagerUsername(text); + user.pendingAction = { type: 'await_username_confirm', data: { username: normalizedUsername, returnTo: action.returnTo || null } }; await ctx.reply( - `โœ… Runewager account linked!\n\n๐Ÿ‘ค Username: *${normalizedUsername}*\n\nYou can now:\nโ€ข Request the 30 SC wager bonus (/bonus)\nโ€ข Join SC giveaways\nโ€ข View full profile (/profile)${nextStepText}`, + `๐Ÿ‘ค You entered: *${normalizedUsername}*\n\nIs this your exact Runewager username?`, { 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')], + [Markup.button.callback('โœ… Yes, Continue', 'confirm_yes_username')], + [Markup.button.callback('โœ๏ธ No, Edit', 'confirm_no_username')], ]), }, ); @@ -4967,6 +5327,34 @@ bot.on('text', async (ctx) => { return; } + // Admin: manually link a Runewager username to a user + if (action.type === 'w30_admin_link_username') { + if (!requireAdmin(ctx)) return; + user.pendingAction = null; + const parts = text.trim().split(/\s+/); + if (parts.length < 2) { + await ctx.reply('Format: ` `\nExample: `6668510825 johnplayer`', { parse_mode: 'Markdown' }); + return; + } + const [rawId, rwUsername] = parts; + const target = findUserByTelegramIdOrRunewager(rawId); + if (!target) { await ctx.reply(`User not found: ${rawId}`); return; } + const normalized = normalizeRunewagerUsername(rwUsername); + target.runewagerUsername = normalized; + target.wagerBonus.affiliateSource = 'GambleCodez'; + trackOnboardingProgress(target); + await ctx.reply(`โœ… Runewager username *${normalized}* linked to TG ID ${target.id} (@${target.tgUsername || 'unknown'}).`, { parse_mode: 'Markdown' }); + // Notify the user + try { + await bot.telegram.sendMessage( + target.id, + `โœ… An admin has linked your Runewager username: *${normalized}*\n\n${AFFILIATE_REMINDER_TEXT}`, + { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('๐ŸŽฏ Request 30 SC Bonus', 'w30_request_start')]]) }, + ); + } catch (_) { /* user may have blocked bot */ } + return; + } + // โ”€โ”€ Inline giveaway wizard custom-text inputs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if (action.type === 'gwiz_await_title') { if (!requireAdmin(ctx)) return; @@ -6265,8 +6653,8 @@ async function runWeeklyWagerReminder() { // eslint-disable-next-line no-await-in-loop await bot.telegram.sendMessage( user.id, - `๐Ÿ‘‹ Friendly reminder:\nIf you have wagered 3,000 SC in any 7-day period under the GambleCodez affiliate, you can request a one-time 30 SC bonus.\nAdmins manually credit the bonus on Runewager โ€” no screenshots needed.`, - wagerReminderKeyboard(), + `๐Ÿ‘‹ Friendly reminder:\n\n${AFFILIATE_REMINDER_TEXT}\n\nIf you have wagered 3,000 SC in any 7-day period under the GambleCodez affiliate, you can request a one-time 30 SC bonus.\nAdmins manually credit the bonus on Runewager โ€” no screenshots needed.`, + { parse_mode: 'Markdown', ...wagerReminderKeyboard() }, ); } catch (e) { // ignore blocked users @@ -6920,6 +7308,574 @@ function startHealthServer() { return server; } + +// ========================= +// โ”€โ”€ 26 Feature Upgrades โ”€โ”€ +// ========================= + +// โ”€โ”€ Admin Activity Log store (Feature 9) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const adminActivityLog = []; +const MAX_ADMIN_LOG = 500; +function logAdminAction(adminId, action, detail = '') { + adminActivityLog.push({ ts: Date.now(), adminId, action, detail: String(detail).slice(0, 200) }); + if (adminActivityLog.length > MAX_ADMIN_LOG) adminActivityLog.shift(); +} + +// โ”€โ”€ Feature 1: Live Giveaway Control Panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('gw_pause', safeAdminHandler('gw_pause', { usage: '/gw_pause ', example: '/gw_pause 3' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const args = ctx.message.text.split(/\s+/); + const gwId = Number(args[1]); + if (!gwId) { await ctx.reply('Usage: /gw_pause '); return; } + const gw = giveawayStore.running.get(gwId); + if (!gw) { await ctx.reply(`โŒ Giveaway #${gwId} not found in running giveaways.`); return; } + if (gw.paused) { await ctx.reply(`โš ๏ธ Giveaway #${gwId} is already paused.`); return; } + gw.paused = true; + gw.pausedAt = Date.now(); + gw.remainingMs = Math.max(0, gw.endTime - Date.now()); + if (gw.endTimer) { clearTimeout(gw.endTimer); gw.endTimer = null; } + (gw.reminders || []).forEach((t) => clearTimeout(t)); + gw.reminders = []; + logAdminAction(ctx.from.id, 'gw_pause', `#${gwId}`); + await ctx.reply( + `โธ *Giveaway #${gwId} paused.*\nTime remaining: ~${Math.ceil(gw.remainingMs / 60000)} min.\nUse /gw_resume ${gwId} to resume.`, + { parse_mode: 'Markdown' }, + ); +})); + +bot.command('gw_resume', safeAdminHandler('gw_resume', { usage: '/gw_resume ', example: '/gw_resume 3' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const args = ctx.message.text.split(/\s+/); + const gwId = Number(args[1]); + if (!gwId) { await ctx.reply('Usage: /gw_resume '); return; } + const gw = giveawayStore.running.get(gwId); + if (!gw) { await ctx.reply(`โŒ Giveaway #${gwId} not found.`); return; } + if (!gw.paused) { await ctx.reply(`โš ๏ธ Giveaway #${gwId} is not paused.`); return; } + gw.paused = false; + gw.endTime = Date.now() + (gw.remainingMs || 60000); + gw.remainingMs = 0; + gw.pausedAt = null; + scheduleGiveawayReminders(gw); + resetGiveawayTimer(gw); + logAdminAction(ctx.from.id, 'gw_resume', `#${gwId}`); + await ctx.reply( + `โ–ถ๏ธ *Giveaway #${gwId} resumed.*\nNew end time: ${new Date(gw.endTime).toISOString()}`, + { parse_mode: 'Markdown' }, + ); +})); + +bot.action(/^gw_pause_(\d+)$/, async (ctx) => { + if (!isAdmin(ctx)) { await ctx.answerCbQuery('Admins only.'); return; } + const gwId = Number(ctx.match[1]); + const gw = giveawayStore.running.get(gwId); + await ctx.answerCbQuery(); + if (!gw) { await ctx.reply(`โŒ Giveaway #${gwId} not found.`); return; } + if (gw.paused) { await ctx.reply('โš ๏ธ Already paused.'); return; } + gw.paused = true; gw.pausedAt = Date.now(); gw.remainingMs = Math.max(0, gw.endTime - Date.now()); + if (gw.endTimer) { clearTimeout(gw.endTimer); gw.endTimer = null; } + (gw.reminders || []).forEach((t) => clearTimeout(t)); gw.reminders = []; + logAdminAction(ctx.from.id, 'gw_pause', `#${gwId}`); + await ctx.reply(`โธ Giveaway #${gwId} paused.`); +}); + +bot.action(/^gw_resume_(\d+)$/, async (ctx) => { + if (!isAdmin(ctx)) { await ctx.answerCbQuery('Admins only.'); return; } + const gwId = Number(ctx.match[1]); + const gw = giveawayStore.running.get(gwId); + await ctx.answerCbQuery(); + if (!gw) { await ctx.reply(`โŒ Giveaway #${gwId} not found.`); return; } + if (!gw.paused) { await ctx.reply('โš ๏ธ Not paused.'); return; } + gw.paused = false; gw.endTime = Date.now() + (gw.remainingMs || 60000); + gw.remainingMs = 0; gw.pausedAt = null; + scheduleGiveawayReminders(gw); resetGiveawayTimer(gw); + logAdminAction(ctx.from.id, 'gw_resume', `#${gwId}`); + await ctx.reply(`โ–ถ๏ธ Giveaway #${gwId} resumed.`); +}); + +// โ”€โ”€ Feature 2: Internal Eligibility Scanner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('scan_eligibility', safeAdminHandler('scan_eligibility', { usage: '/scan_eligibility ', example: '/scan_eligibility 3' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const args = ctx.message.text.split(/\s+/); + const gwId = Number(args[1]); + if (!gwId) { await ctx.reply('Usage: /scan_eligibility '); return; } + const gw = giveawayStore.running.get(gwId) || giveawayStore.history.get(gwId); + if (!gw) { await ctx.reply(`โŒ Giveaway #${gwId} not found.`); return; } + const allUsers = Array.from(userStore.values()); + let eligible = 0; let ineligible = 0; const missingReasons = {}; + for (const user of allUsers) { + const result = evaluateEligibility(user, gw); + if (result.ok) { eligible++; } else { ineligible++; const r = result.message || 'Unknown'; missingReasons[r] = (missingReasons[r] || 0) + 1; } + } + const total = allUsers.length; + const breakdown = Object.entries(missingReasons).sort((a, b) => b[1] - a[1]).slice(0, 8) + .map(([r, n]) => ` โ€ข ${n}x โ€” ${r.slice(0, 80)}`).join('\n'); + logAdminAction(ctx.from.id, 'scan_eligibility', `#${gwId} โ†’ ${eligible}/${total} eligible`); + await ctx.reply( + `๐Ÿ” *Eligibility Scan โ€” Giveaway #${gwId}*\n\n` + + `Total users scanned: *${total}*\nโœ… Eligible: *${eligible}*\nโŒ Ineligible: *${ineligible}*\n\n` + + (breakdown ? `*Top reasons ineligible:*\n${breakdown}` : ''), + { parse_mode: 'Markdown' }, + ); +})); + +// โ”€โ”€ Feature 3: Auto-Expiring Referral Boosts cron โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function expireReferralBoosts() { + const now = Date.now(); + for (const [, user] of userStore) { + if (user.boostExpiresAt > 0 && user.boostExpiresAt <= now) { + referralStore.archivedBoosts.push({ userId: user.id, tgUsername: user.tgUsername, expiredAt: now, referralCount: user.referralCount || 0 }); + user.boostExpiresAt = 0; + } + } + if (referralStore.archivedBoosts.length > 1000) referralStore.archivedBoosts.splice(0, referralStore.archivedBoosts.length - 1000); +} + +// โ”€โ”€ Feature 4: Funnel Analytics โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('funnel', safeAdminHandler('funnel', { usage: '/funnel', example: '/funnel' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const users = Array.from(userStore.values()); + const total = users.length; + const pct = (n) => (total > 0 ? `${((n / total) * 100).toFixed(1)}%` : '0%'); + const ageOk = users.filter((u) => u.ageConfirmed).length; + const channelJoined = users.filter((u) => u.hasJoinedChannel).length; + const groupJoined = users.filter((u) => u.hasJoinedGroup).length; + const linked = users.filter((u) => !needsLinkedUsername(u)).length; + const verified = users.filter((u) => u.verified).length; + const promoClaimed = users.filter((u) => u.claimedPromo).length; + const walkthroughDone = users.filter((u) => u.walkthrough && u.walkthrough.completed.size >= walkthroughCatalog.length).length; + const bonusSent = users.filter((u) => u.wagerBonus && u.wagerBonus.status === 'bonus_sent').length; + logAdminAction(ctx.from.id, 'funnel', `total=${total}`); + await ctx.reply( + `๐Ÿ“Š *Onboarding Funnel โ€” All Time*\n\n๐Ÿ‘ฅ Total users: *${total}*\n` + + `๐Ÿ”ž Age confirmed: *${ageOk}* (${pct(ageOk)})\n๐Ÿ“ข Joined channel: *${channelJoined}* (${pct(channelJoined)})\n` + + `๐Ÿ’ฌ Joined group: *${groupJoined}* (${pct(groupJoined)})\n๐Ÿ”— Account linked: *${linked}* (${pct(linked)})\n` + + `โœ… Verified: *${verified}* (${pct(verified)})\n๐ŸŽ Promo claimed: *${promoClaimed}* (${pct(promoClaimed)})\n` + + `๐Ÿ“– Walkthrough done: *${walkthroughDone}* (${pct(walkthroughDone)})\n๐Ÿ’ฐ 30 SC bonus sent: *${bonusSent}* (${pct(bonusSent)})\n`, + { parse_mode: 'Markdown' }, + ); +})); + +// โ”€โ”€ Feature 5: Broadcast Queue with Retries โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('broadcast_retry', safeAdminHandler('broadcast_retry', { usage: '/broadcast_retry ', example: '/broadcast_retry Hello!' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const text = ctx.message.text.replace(/^\/broadcast_retry\s*/i, '').trim(); + if (!text) { await ctx.reply('Usage: /broadcast_retry '); return; } + let sent = 0; let failed = 0; const stillFailed = []; + for (const entry of broadcastFailedUsers) { + const user = userStore.get(entry.userId); + if (!user) continue; + try { + // eslint-disable-next-line no-await-in-loop + await bot.telegram.sendMessage(entry.userId, text, { parse_mode: 'Markdown' }); + sent++; user.unreachable = false; + } catch (e) { failed++; stillFailed.push({ ...entry, lastError: e.message, failedAt: Date.now() }); } + } + broadcastFailedUsers.length = 0; broadcastFailedUsers.push(...stillFailed); + logAdminAction(ctx.from.id, 'broadcast_retry', `sent=${sent} failed=${failed}`); + await ctx.reply(`๐Ÿ“ก Broadcast retry complete.\nโœ… Sent: ${sent}\nโŒ Still failed: ${failed}`); +})); + +bot.command('broadcast_failed', safeAdminHandler('broadcast_failed', { usage: '/broadcast_failed', example: '/broadcast_failed' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + if (!broadcastFailedUsers.length) { await ctx.reply('โœ… No permanently failed broadcast users.'); return; } + const lines = broadcastFailedUsers.slice(0, 30).map((e) => { + const user = userStore.get(e.userId); + const name = user ? (user.tgUsername ? `@${user.tgUsername}` : `user${e.userId}`) : `id:${e.userId}`; + return `โ€ข ${name} โ€” ${(e.lastError || '').slice(0, 60)}`; + }); + await ctx.reply(`๐Ÿ“‹ *Broadcast Failed Users (${broadcastFailedUsers.length})*\n\n${lines.join('\n')}`, { parse_mode: 'Markdown' }); +})); + +// โ”€โ”€ Feature 7: Internal Winner Picker (cryptographic seed) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('pick_winner', safeAdminHandler('pick_winner', { usage: '/pick_winner [count]', example: '/pick_winner 3 1' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const args = ctx.message.text.split(/\s+/); + const gwId = Number(args[1]); + const pickCount = Math.max(1, Number(args[2]) || 1); + if (!gwId) { await ctx.reply('Usage: /pick_winner [count]'); return; } + const gw = giveawayStore.running.get(gwId) || giveawayStore.history.get(gwId); + if (!gw) { await ctx.reply(`โŒ Giveaway #${gwId} not found.`); return; } + const pool = Array.from((gw.participants || new Map()).values()); + if (!pool.length) { await ctx.reply(`โŒ No participants in giveaway #${gwId}.`); return; } + const seed = require('crypto').randomBytes(8).toString('hex'); + const picked = pickRandomUnique(pool, Math.min(pickCount, pool.length)); + gw.winningSeed = { seed, pickedAt: Date.now(), pickedBy: ctx.from.id }; + const winnerLines = picked.map((w, i) => `${i + 1}. @${w.tgUsername || `user${w.userId}`} (RW: ${w.runewagerUsername || 'unlinked'})`).join('\n'); + logAdminAction(ctx.from.id, 'pick_winner', `#${gwId} seed=${seed} picked=${picked.length}`); + await ctx.reply( + `๐ŸŽฐ *Manual Winner Pick โ€” Giveaway #${gwId}*\n\n${winnerLines}\n\n๐ŸŒฑ Seed: \`${seed}\`\n๐Ÿ‘ค Picked by: admin ${ctx.from.id}\n๐Ÿ• At: ${new Date().toISOString()}`, + { parse_mode: 'Markdown' }, + ); +})); + +// โ”€โ”€ Feature 8: Group Validation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('approve_group', safeAdminHandler('approve_group', { usage: '/approve_group ', example: '/approve_group -1001234567890' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const chatId = Number((ctx.message.text.split(/\s+/)[1])); + if (!chatId) { await ctx.reply('Usage: /approve_group '); return; } + approvedGroupsStore.add(chatId); logAdminAction(ctx.from.id, 'approve_group', String(chatId)); + await ctx.reply(`โœ… Group \`${chatId}\` added to approved list.`, { parse_mode: 'Markdown' }); +})); + +bot.command('unapprove_group', safeAdminHandler('unapprove_group', { usage: '/unapprove_group ', example: '/unapprove_group -1001234567890' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const chatId = Number((ctx.message.text.split(/\s+/)[1])); + if (!chatId) { await ctx.reply('Usage: /unapprove_group '); return; } + approvedGroupsStore.delete(chatId); logAdminAction(ctx.from.id, 'unapprove_group', String(chatId)); + await ctx.reply(`๐Ÿ—‘ Group \`${chatId}\` removed from approved list.`, { parse_mode: 'Markdown' }); +})); + +bot.command('list_groups', safeAdminHandler('list_groups', { usage: '/list_groups', example: '/list_groups' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + if (!approvedGroupsStore.size) { await ctx.reply('No approved groups configured.'); return; } + const lines = Array.from(approvedGroupsStore).map((id) => `โ€ข \`${id}\``).join('\n'); + await ctx.reply(`โœ… *Approved Groups (${approvedGroupsStore.size})*\n\n${lines}`, { parse_mode: 'Markdown' }); +})); + +// โ”€โ”€ Feature 9: Admin Activity Log โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('admin_log', safeAdminHandler('admin_log', { usage: '/admin_log [lines]', example: '/admin_log 20' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const n = Math.min(50, Math.max(1, Number((ctx.message.text.split(/\s+/)[1])) || 20)); + const recent = adminActivityLog.slice(-n).reverse(); + if (!recent.length) { await ctx.reply('๐Ÿ“‹ No admin actions logged yet.'); return; } + const entries = recent.map((e) => `${new Date(e.ts).toISOString().slice(11, 19)} admin:${e.adminId} โ€” ${e.action}${e.detail ? ` [${e.detail}]` : ''}`).join('\n'); + await ctx.reply(`๐Ÿ“‹ *Admin Activity Log (last ${recent.length})*\n\`\`\`\n${entries.slice(0, 3800)}\n\`\`\``, { parse_mode: 'Markdown' }); +})); + +// โ”€โ”€ Feature 10: Promo Cooldown Manager โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('promo_cooldown', safeAdminHandler('promo_cooldown', { usage: '/promo_cooldown ', example: '/promo_cooldown 7' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const days = Number(ctx.message.text.split(/\s+/)[1]); + if (isNaN(days) || days < 0) { await ctx.reply('Usage: /promo_cooldown (0 = no cooldown)'); return; } + promoStore.cooldownDays = days; logAdminAction(ctx.from.id, 'promo_cooldown', `${days} days`); + await ctx.reply(days === 0 ? 'โœ… Promo cooldown disabled.' : `โœ… Promo cooldown set to *${days} day(s)* between claims per user.`, { parse_mode: 'Markdown' }); +})); + +// โ”€โ”€ Feature 11: Discord Step Monitor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('discord_stats', safeAdminHandler('discord_stats', { usage: '/discord_stats', example: '/discord_stats' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const users = Array.from(userStore.values()); + const total = users.length; + const pct = (n) => (total > 0 ? `${((n / total) * 100).toFixed(1)}%` : '0%'); + const verified = users.filter((u) => u.verified).length; + const sawStep = users.filter((u) => u.sawGambleCodezStep).length; + const stuck = users.filter((u) => u.sawGambleCodezStep && !u.verified).length; + logAdminAction(ctx.from.id, 'discord_stats'); + await ctx.reply( + `๐ŸŽฎ *Discord Verification Stats*\n\n๐Ÿ‘ฅ Total: *${total}*\n๐Ÿ‘€ Saw Discord step: *${sawStep}* (${pct(sawStep)})\n` + + `โœ… Verified: *${verified}* (${pct(verified)})\nโŒ Not verified: *${total - verified}* (${pct(total - verified)})\n๐Ÿ”„ Stuck: *${stuck}*\n`, + { parse_mode: 'Markdown' }, + ); +})); + +// โ”€โ”€ Feature 12: Auto-Generated Giveaway Summary Card โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('gw_graphic', safeAdminHandler('gw_graphic', { usage: '/gw_graphic ', example: '/gw_graphic 3' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const gwId = Number(ctx.message.text.split(/\s+/)[1]); + if (!gwId) { await ctx.reply('Usage: /gw_graphic '); return; } + const gw = giveawayStore.running.get(gwId) || giveawayStore.history.get(gwId); + if (!gw) { await ctx.reply(`โŒ Giveaway #${gwId} not found.`); return; } + const statusLabel = giveawayStore.running.has(gwId) ? (gw.paused ? 'PAUSED' : 'LIVE') : 'ENDED'; + const parts = gw.participants ? gw.participants.size : 0; + const endStr = new Date(gw.endTime).toISOString().slice(0, 16).replace('T', ' '); + const reqs = [ + gw.requireChannel ? 'Channel join' : null, gw.requireGroup ? 'Group join' : null, + gw.requireLinked ? 'Linked account' : null, gw.requireVerified ? 'Verified' : null, + gw.requireAge ? '18+ confirmed' : null, gw.requirePromo ? 'Promo claimed' : null, + ].filter(Boolean); + const p = (s, n) => String(s).slice(0, n).padEnd(n); + const card = [ + `+---------------------------------+`, + `| GIVEAWAY #${p(gwId, 20)}|`, + `| Status: ${p(statusLabel, 23)}|`, + `| ${p(`${gw.scPerWinner} SC x ${gw.maxWinners} winners`, 31)}|`, + `| Joined: ${p(parts, 23)}|`, + `| Ends: ${p(endStr, 23)}|`, + reqs.length ? `| Requires: ${p(reqs.join(', '), 21)}|` : null, + `+---------------------------------+`, + ].filter(Boolean).join('\n'); + logAdminAction(ctx.from.id, 'gw_graphic', `#${gwId}`); + await ctx.reply(`\`\`\`\n${card}\n\`\`\``, { parse_mode: 'Markdown' }); +})); + +// โ”€โ”€ Feature 13: Stuck User Guided Checklist โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('stuck', async (ctx) => { + const user = getUser(ctx); + const steps = []; + if (!user.ageConfirmed) steps.push('๐Ÿ”ž Confirm 18+ โ€” tap *Age Confirmation* in the main menu.'); + if (!user.hasJoinedChannel) steps.push('๐Ÿ“ข Join the *GambleCodez Channel* โ€” tap the button in the main menu.'); + if (!user.hasJoinedGroup) steps.push('๐Ÿ’ฌ Join the *GambleCodez Group* โ€” tap the button in the main menu.'); + if (needsLinkedUsername(user)) steps.push('๐Ÿ”— Link your *Runewager username* โ€” use /linkrunewager or tap "Link Account".'); + if (!user.verified) steps.push('โœ… Verify your *Runewager account* โ€” tap "Create / Verify Account" in the menu.'); + if (!user.claimedPromo) steps.push('๐ŸŽ Claim your *promo bonus* โ€” tap "Claim Bonus" in the main menu.'); + if (user.walkthrough && user.walkthrough.completed.size < walkthroughCatalog.length) + steps.push(`๐Ÿ“– Complete the *walkthrough* โ€” use /walkthrough (${user.walkthrough.completed.size}/${walkthroughCatalog.length} done).`); + if (!steps.length) { + await ctx.reply('โœ… *You\'re all set!* All setup steps are complete.', { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('๐Ÿ  Main Menu', 'to_main_menu')]]) }); + return; + } + await ctx.reply( + `๐Ÿ†˜ *Stuck? Here\'s what you need to do:*\n\n${steps.map((s, i) => `${i + 1}. ${s}`).join('\n\n')}\n\n_Tap /menu to get started._`, + { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('๐Ÿ  Go to Menu', 'to_main_menu')]]) }, + ); +}); + +// โ”€โ”€ Feature 14: Fix-My-Account Wizard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('fixaccount', async (ctx) => { + const user = getUser(ctx); + const issues = []; + if (needsLinkedUsername(user)) issues.push({ label: '๐Ÿ”— Account not linked', action: 'Use /linkrunewager to link your username.' }); + if (!user.verified) issues.push({ label: 'โœ… Account not verified', action: 'Tap "Create / Verify Account" in the main menu.' }); + if (!user.hasJoinedChannel) issues.push({ label: '๐Ÿ“ข Not joined channel', action: 'Tap "Join GambleCodez Channel" in the main menu.' }); + if (!user.hasJoinedGroup) issues.push({ label: '๐Ÿ’ฌ Not joined group', action: 'Tap "Join GambleCodez Group" in the main menu.' }); + if (!user.ageConfirmed) issues.push({ label: '๐Ÿ”ž Age not confirmed', action: 'Tap Age Confirmation in the menu.' }); + if (!issues.length) { await ctx.reply('โœ… *No account issues found!* Everything looks good.', { parse_mode: 'Markdown' }); return; } + const issueText = issues.map((iss, i) => `${i + 1}. ${iss.label}\n โ†’ ${iss.action}`).join('\n\n'); + await ctx.reply( + `๐Ÿ”ง *Fix-My-Account Wizard*\n\nI found *${issues.length}* issue(s):\n\n${issueText}\n\nTap *Main Menu* to resolve each one.`, + { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('๐Ÿ  Main Menu', 'to_main_menu'), Markup.button.callback('๐Ÿ“‹ Get Help', 'menu_bugreport')]]) }, + ); +}); + +// โ”€โ”€ Feature 15: Discord Step Confirmation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('discord_confirm', async (ctx) => { + const user = getUser(ctx); + if (user.verified) { await ctx.reply('โœ… Your Discord verification is already confirmed!'); return; } + user.sawGambleCodezStep = true; + await ctx.reply( + '๐ŸŽฎ *Discord Verification*\n\n1. Join the Runewager Discord server\n2. Go to the verification channel\n3. Follow the instructions\n\nOnce verified, tap *"Create / Verify Account"* in the main menu to sync.', + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.url('๐ŸŽฎ Join Discord', process.env.RW_DISCORD_JOIN || 'https://discord.gg/runewagers')], + [Markup.button.callback('โœ… I\'ve Verified โ€” Sync Now', 'menu_verify_account')], + [Markup.button.callback('๐Ÿ  Main Menu', 'to_main_menu')], + ]), + }, + ); +}); + +// โ”€โ”€ Feature 16: Personalized Giveaway Feed โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('mygiveaways', async (ctx) => { + const user = getUser(ctx); + const running = Array.from(giveawayStore.running.values()); + if (!running.length) { await ctx.reply('๐Ÿ˜” No giveaways running right now. Check back soon!', Markup.inlineKeyboard([[Markup.button.callback('๐Ÿ  Main Menu', 'to_main_menu')]])); return; } + const eligible = []; const notEligible = []; + for (const gw of running) { + if (gw.paused) continue; + const alreadyIn = user.giveawayJoinedIds && user.giveawayJoinedIds.has(gw.id); + const result = evaluateEligibility(user, gw); + if (!alreadyIn) { if (result.ok) eligible.push(gw); else notEligible.push({ gw, reason: result.message }); } + } + let text = `๐ŸŽ‰ *Your Personalized Giveaway Feed*\n\n`; + if (eligible.length) { + text += `โœ… *You can join (${eligible.length}):*\n`; + for (const gw of eligible) text += `\nโ€ข *#${gw.id}* โ€” ๐Ÿ’ฐ ${gw.scPerWinner} SC ร— ${gw.maxWinners} winners`; + text += '\n'; + } + if (notEligible.length) { + text += `\nโŒ *Not yet eligible (${notEligible.length}):*\n`; + for (const { gw, reason } of notEligible.slice(0, 3)) text += `\nโ€ข *#${gw.id}* โ€” ${(reason || '').slice(0, 80)}`; + } + if (!eligible.length && !notEligible.length) text += 'All running giveaways are paused or you\'ve already joined them.'; + const buttons = eligible.slice(0, 3).map((gw) => [Markup.button.callback(`๐ŸŽ Join #${gw.id}`, `gw_join_${gw.id}`)]); + buttons.push([Markup.button.callback('๐Ÿ  Main Menu', 'to_main_menu')]); + await ctx.reply(text, { parse_mode: 'Markdown', ...Markup.inlineKeyboard(buttons) }); +}); + +// โ”€โ”€ Feature 17: Auto-DM eligibility fix helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +async function dmEligibilityFix(user, result) { + if (!user || !result || result.ok) return; + try { + await bot.telegram.sendMessage( + user.id, + `๐Ÿ”” *Action Required for Giveaway*\n\n${result.message || 'You don\'t meet one or more requirements.'}\n\nTap /stuck for a full checklist or /menu to fix it now.`, + { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('๐Ÿ  Fix It Now', 'to_main_menu')]]) }, + ); + } catch (e) { user.unreachable = true; } +} + +// โ”€โ”€ Feature 18: Daily Streak Check-In โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const STREAK_XP_PER_DAY = 10; +const STREAK_MILESTONES = [3, 7, 14, 30, 60, 100]; +bot.command('checkin', async (ctx) => { + const user = getUser(ctx); + const now = Date.now(); + const DAY_MS = 24 * 60 * 60 * 1000; + const last = user.streak.lastCheckInAt || 0; + const daysSince = last > 0 ? Math.floor((now - last) / DAY_MS) : 999; + if (daysSince === 0) { + await ctx.reply(`โฐ Already checked in today!\nStreak: *${user.streak.count} day(s)*\nCome back tomorrow!`, { parse_mode: 'Markdown' }); + return; + } + const streakBroken = last > 0 && daysSince > 1; + user.streak.count = streakBroken ? 1 : (user.streak.count || 0) + 1; + user.streak.lastCheckInAt = now; + if (user.streak.count > (user.streak.longestStreak || 0)) user.streak.longestStreak = user.streak.count; + const xpGained = STREAK_XP_PER_DAY + (user.streak.count >= 7 ? 5 : 0); + user.profileXP = (user.profileXP || 0) + xpGained; + const isMilestone = STREAK_MILESTONES.includes(user.streak.count); + if (isMilestone) user.badges.add(`streak_${user.streak.count}d`); + let msg = streakBroken ? `๐Ÿ˜ข *Streak broken!* Starting fresh.\n\n` : `๐Ÿ”ฅ *Daily Check-In!*\n\n`; + msg += `๐Ÿ“… Streak: *${user.streak.count} day(s)* | ๐Ÿ† Best: *${user.streak.longestStreak}*\n`; + msg += `โšก *+${xpGained} XP* (total: ${user.profileXP})`; + if (isMilestone) msg += `\n\n๐ŸŽ– *${user.streak.count}-Day Streak milestone unlocked!*`; + const next = STREAK_MILESTONES.find((m) => m > user.streak.count); + if (next) msg += `\n๐Ÿ“Š Next milestone: ${next} days`; + await ctx.reply(msg, { parse_mode: 'Markdown' }); +}); + +// โ”€โ”€ Feature 19: Multi-Metric Bot Leaderboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('top', async (ctx) => { + const users = Array.from(userStore.values()).filter((u) => u.interactedAtLeastOnce); + const sort = (key) => [...users].sort((a, b) => (typeof key === 'function' ? key(b) - key(a) : (b[key] || 0) - (a[key] || 0))).slice(0, 5); + const fmt = (arr, key) => arr.map((u, i) => `${i + 1}. ${u.tgUsername ? `@${u.tgUsername}` : `User${u.id}`} โ€” ${typeof key === 'function' ? key(u) : (u[key] || 0)}`).join('\n') || 'No data yet.'; + const byRefs = sort('referralCount'); + const byStreak = sort((u) => (u.streak && u.streak.count) || 0); + const byGw = sort((u) => (u.giveawayJoinedIds ? u.giveawayJoinedIds.size : 0)); + const byXP = sort('profileXP'); + await ctx.reply( + `๐Ÿ† *Community Leaderboard*\n\n` + + `๐Ÿ”— *Top Referrers:*\n${fmt(byRefs, (u) => `${u.referralCount || 0} refs`)}\n\n` + + `๐Ÿ”ฅ *Highest Streaks:*\n${fmt(byStreak, (u) => `${(u.streak && u.streak.count) || 0} days`)}\n\n` + + `๐ŸŽ‰ *Most Giveaways Joined:*\n${fmt(byGw, (u) => `${u.giveawayJoinedIds ? u.giveawayJoinedIds.size : 0} giveaways`)}\n\n` + + `โšก *Top XP:*\n${fmt(byXP, (u) => `${u.profileXP || 0} XP`)}`, + { parse_mode: 'Markdown' }, + ); +}); + +// โ”€โ”€ Feature 20: Referral Boost Meter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('boostmeter', async (ctx) => { + const user = getUser(ctx); + const now = Date.now(); + const boostActive = user.boostExpiresAt > now; + const refCount = user.referralCount || 0; + const threshold = 3; + const progress = Math.min(refCount % threshold, threshold); + const bar = 'โ–ˆ'.repeat(progress) + 'โ–‘'.repeat(threshold - progress); + let msg = `๐Ÿš€ *Referral Boost Meter*\n\n`; + if (boostActive) msg += `โœ… *Boost ACTIVE!* 2ร— SC on wins.\nโณ Expires in: *${Math.ceil((user.boostExpiresAt - now) / 3600000)}h*\n\n`; + else msg += `๐Ÿ˜ด No active boost.\n\n`; + msg += `๐Ÿ“Š Progress: [${bar}] ${progress}/${threshold}\n๐Ÿ‘ฅ Total referrals: *${refCount}*\n`; + msg += `๐Ÿ’ก Refer ${Math.max(0, threshold - progress)} more friend(s) to activate your next boost!`; + await ctx.reply(msg, { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('๐Ÿ“ค My Referral Link', 'menu_referral')]]) }); +}); + +// โ”€โ”€ Feature 21: "Am I Eligible?" Check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('eligible', async (ctx) => { + const user = getUser(ctx); + const gwId = Number(ctx.message.text.split(/\s+/)[1]); + if (!gwId) { + const running = Array.from(giveawayStore.running.values()).filter((g) => !g.paused); + if (!running.length) { await ctx.reply('No giveaways are currently running.'); return; } + const lines = running.map((gw) => { const r = evaluateEligibility(user, gw); return `โ€ข *#${gw.id}*: ${r.ok ? 'โœ… Eligible' : `โŒ ${(r.message || '').slice(0, 60)}`}`; }); + await ctx.reply(`๐ŸŽฏ *Your Giveaway Eligibility*\n\n${lines.join('\n')}`, { parse_mode: 'Markdown' }); + return; + } + const gw = giveawayStore.running.get(gwId); + if (!gw) { await ctx.reply(`โŒ Giveaway #${gwId} is not currently running.`); return; } + const result = evaluateEligibility(user, gw); + if (result.ok) { + await ctx.reply(`โœ… *You are eligible for Giveaway #${gwId}!*\n\nTap below to join now.`, { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback(`๐ŸŽ Join #${gwId}`, `gw_join_${gwId}`)]]) }); + } else { + await ctx.reply(`โŒ *Not eligible for Giveaway #${gwId}*\n\n${result.message || 'Requirements not met.'}`, { parse_mode: 'Markdown', ...(result.keyboard || Markup.inlineKeyboard([[Markup.button.callback('๐Ÿ  Main Menu', 'to_main_menu')]])) }); + } +}); + +bot.action(/^gw_elig_check_(\d+)$/, async (ctx) => { + const user = getUser(ctx); const gwId = Number(ctx.match[1]); + const gw = giveawayStore.running.get(gwId); await ctx.answerCbQuery(); + if (!gw) { await ctx.reply(`Giveaway #${gwId} is not running.`); return; } + const result = evaluateEligibility(user, gw); + if (result.ok) await ctx.reply(`โœ… You are eligible for Giveaway #${gwId}!`, Markup.inlineKeyboard([[Markup.button.callback(`๐ŸŽ Join #${gwId}`, `gw_join_${gwId}`)]])); + else await ctx.reply(`โŒ ${result.message || 'Not eligible.'}`, result.keyboard || {}); +}); + +// โ”€โ”€ Feature 22: Private Giveaway History โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('gwhistory', async (ctx) => { + const user = getUser(ctx); + const history = user.giveawayHistory || []; + if (!history.length) { await ctx.reply('๐Ÿ“ญ You haven\'t joined any giveaways yet.\n\nUse /mygiveaways to find ones you can enter!'); return; } + const lines = history.slice(-20).reverse().map((e) => `โ€ข #${e.id} โ€” ${new Date(e.joinedAt).toLocaleDateString('en-GB')}${e.won ? ` ๐Ÿ† WON ${e.wonSC} SC` : ''}`); + const wins = history.filter((e) => e.won).length; + const totalSC = history.filter((e) => e.won).reduce((s, e) => s + (e.wonSC || 0), 0); + await ctx.reply(`๐ŸŽ‰ *Your Giveaway History*\n\n๐Ÿ“Š Joined: ${history.length} | Wins: ${wins} | SC won: ${totalSC}\n\n${lines.join('\n')}`, { parse_mode: 'Markdown' }); +}); + +// โ”€โ”€ Feature 23: Promo Eligibility Check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('promocheck', async (ctx) => { + const user = getUser(ctx); + if (user.claimedPromo) { await ctx.reply('โœ… You have already claimed the promo code!'); return; } + const issues = []; + if (!promoStore.active) issues.push('โŒ The promo is currently *inactive*.'); + if (promoStore.remainingClaims <= 0) issues.push('โŒ The promo has *no remaining claims*.'); + if (promoStore.claimsByUser && promoStore.claimsByUser.has(user.id)) issues.push('โŒ You have *already claimed* this code.'); + if (promoStore.cooldownDays > 0 && user.wagerBonus && user.wagerBonus.lastRequestAt > 0) { + const cd = promoStore.cooldownDays * 86400000; + const elapsed = Date.now() - user.wagerBonus.lastRequestAt; + if (elapsed < cd) issues.push(`โณ Cooldown active โ€” ${Math.ceil((cd - elapsed) / 86400000)} day(s) remaining.`); + } + if (!issues.length) { + await ctx.reply(`โœ… *You are eligible to claim the promo!*\n\nCode: \`${promoStore.code}\`\nAmount: *${promoStore.amountSC} SC*\nRemaining claims: *${promoStore.remainingClaims}*\n\nTap below to claim now.`, { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('๐ŸŽ Claim Promo Now', 'menu_claim_bonus')]]) }); + } else { + await ctx.reply(`๐Ÿ“‹ *Promo Eligibility Check*\n\n${issues.join('\n')}\n\nContact support if you have questions.`, { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('๐Ÿ“ฉ Contact Support', 'menu_bugreport')]]) }); + } +}); + +// โ”€โ”€ Feature 24: Language Info โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.command('language', async (ctx) => { + const user = getUser(ctx); + if (!user.language && ctx.from.language_code) user.language = ctx.from.language_code; + await ctx.reply( + `๐ŸŒ *Language Settings*\n\nDetected language: \`${user.language || ctx.from.language_code || 'unknown'}\`\n\nThis bot currently operates in *English only*.\nYour language code has been recorded for future multi-language support.`, + { parse_mode: 'Markdown' }, + ); +}); + +// โ”€โ”€ Feature 25: DM-Only Support Portal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const SUPPORT_ISSUE_TYPES = ['Account not linked', 'Verification issue', 'Promo code problem', 'Giveaway issue', 'Bonus not received', 'Other']; + +bot.command('support', async (ctx) => { + if (ctx.chat.type !== 'private') { await ctx.reply('๐Ÿ“ฉ The support portal is only available in private DM. Please message me directly.'); return; } + const user = getUser(ctx); + user.supportTicket = { step: 1, data: {}, startedAt: Date.now() }; + const buttons = SUPPORT_ISSUE_TYPES.map((type, i) => [Markup.button.callback(type, `support_type_${i}`)]); + buttons.push([Markup.button.callback('โŒ Cancel', 'support_cancel')]); + await ctx.reply('๐Ÿ“ฉ *Support Portal*\n\nWhat can we help you with?\nSelect an issue type below:', { parse_mode: 'Markdown', ...Markup.inlineKeyboard(buttons) }); +}); + +bot.action(/^support_type_(\d+)$/, async (ctx) => { + const user = getUser(ctx); await ctx.answerCbQuery(); + if (!user.supportTicket || user.supportTicket.step !== 1) { await ctx.reply('Session expired. Use /support to start again.'); return; } + const issueType = SUPPORT_ISSUE_TYPES[Number(ctx.match[1])] || 'Other'; + user.supportTicket.data.issueType = issueType; + user.supportTicket.step = 2; + await ctx.reply(`๐Ÿ“ *Issue: ${issueType}*\n\nPlease describe your issue in a few words. Type your message below:`, { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โŒ Cancel', 'support_cancel')]]) }); +}); + +bot.action('support_cancel', async (ctx) => { + const user = getUser(ctx); await ctx.answerCbQuery('Cancelled.'); + user.supportTicket = null; + await ctx.reply('โŒ Support ticket cancelled.'); +}); + +// โ”€โ”€ Feature 26: Weekly Auto-DM Boost Reminder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +async function runWeeklyBoostReminder() { + const now = Date.now(); const WEEK_MS_26 = 7 * 24 * 60 * 60 * 1000; let sent = 0; + for (const [, user] of userStore) { + if (!user.interactedAtLeastOnce || user.optOutBroadcasts || user.unreachable) continue; + if (now - (user.lastWeeklyBoostDmAt || 0) < WEEK_MS_26) continue; + if ((user.referralCount || 0) >= 3) continue; + const remaining = 3 - (user.referralCount || 0); + try { + // eslint-disable-next-line no-await-in-loop + await bot.telegram.sendMessage(user.id, `๐Ÿš€ *Weekly Boost Reminder*\n\nYou\'re *${remaining}* referral(s) away from a giveaway win multiplier boost!\n\nUse /boostmeter to check your progress or /referral to get your link.`, { parse_mode: 'Markdown' }); + user.lastWeeklyBoostDmAt = now; sent++; + } catch (e) { user.unreachable = true; broadcastFailedUsers.push({ userId: user.id, lastError: e.message, failedAt: now }); } + } + logEvent('info', 'Weekly boost reminder sent', { sent }); +} + // ========================= // Startup // ========================= @@ -6986,6 +7942,16 @@ if (process.env.CI === 'true' || process.env.DISABLE_RUNTIME === '1') { referralStore.weekly.clear(); }, WEEK_MS); + // Feature 3: Expire referral boosts every hour + setInterval(() => { + try { expireReferralBoosts(); } catch (e) { logEvent('error', 'expireReferralBoosts failed', { error: e.message }); } + }, 60 * 60 * 1000); + + // Feature 26: Weekly boost reminder DM + setInterval(() => { + runWeeklyBoostReminder().catch((e) => logEvent('error', 'runWeeklyBoostReminder failed', { error: e.message })); + }, WEEK_MS); + setInterval(() => { try { if (fs.existsSync(runtimeStateFile)) { diff --git a/prod-run.sh b/prod-run.sh index 6f29904..035041b 100755 --- a/prod-run.sh +++ b/prod-run.sh @@ -265,6 +265,21 @@ LRCONF ensure_logrotate +# --------------------------------------------------------- +# 9b) Weekly disk-space protection cron job +# --------------------------------------------------------- +DISK_PROTECT_SCRIPT="${PROJECT_DIR}/scripts/disk-protect.sh" +if [[ -f "$DISK_PROTECT_SCRIPT" ]] && command -v crontab >/dev/null 2>&1; then + disk_cron_line="0 3 * * 0 $DISK_PROTECT_SCRIPT >> ${PROJECT_DIR}/logs/disk-protect.log 2>&1 # ${APP_NAME}_disk_protect" + current_cron="$(crontab -l 2>/dev/null || true)" + if ! printf '%s\n' "$current_cron" | grep -Fq "${APP_NAME}_disk_protect"; then + { printf '%s\n' "$current_cron"; printf '%s\n' "$disk_cron_line"; } | crontab - + say "Weekly disk-protect cron job installed" + else + say "Weekly disk-protect cron already present โ€” skipping" + fi +fi + # --------------------------------------------------------- # 10) Structured diagnostics # --------------------------------------------------------- diff --git a/scripts/disk-protect.sh b/scripts/disk-protect.sh new file mode 100755 index 0000000..c894d81 --- /dev/null +++ b/scripts/disk-protect.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# ============================================================= +# disk-protect.sh โ€” Runewager Bot Disk Space Protection +# +# Runs automatically (weekly cron / systemd timer) or manually. +# Safe: never deletes .env, state files, or anything critical. +# +# Schedule (add to crontab or run via systemd timer): +# 0 3 * * 0 /path/to/Runewager/scripts/disk-protect.sh +# ============================================================= + +set -euo pipefail + +APP_DIR="${APP_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +LOG_DIR="${APP_DIR}/logs" +DATA_DIR="${APP_DIR}/data" +BACKUP_DIR="${DATA_DIR}/backups" + +# Max ages (days) +LOG_MAX_DAYS=7 +SNAPSHOT_MAX_DAYS=30 +TEMP_MAX_DAYS=3 + +# Journal vacuum target size +JOURNAL_VACUUM_MB=300 + +# โ”€โ”€ Logging โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +log() { echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] [disk-protect] $*"; } +warn() { echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] [disk-protect] WARN: $*" >&2; } + +# โ”€โ”€ Helper: delete files found by find and log each one reliably โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Uses process substitution + null-delimited names to handle filenames with +# spaces or special characters, and separates print/delete so logging is never +# suppressed by find implementation differences. +delete_found() { + # Usage: delete_found + # Example: delete_found "$LOG_DIR" -type f -mtime +7 + local _count=0 + while IFS= read -r -d '' f; do + if rm -f "$f"; then + log " Deleted: $f" + _count=$((_count + 1)) + else + warn " Failed to delete: $f" + fi + done < <(find "$@" -print0 2>/dev/null) + log " Total deleted: $_count file(s)" +} + +# โ”€โ”€ 1. Log rotation (via logrotate if available) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +log "Step 1: Log rotation" +if command -v logrotate >/dev/null 2>&1; then + LOGROTATE_CONF="${APP_DIR}/runewager.logrotate" + if [[ -f "$LOGROTATE_CONF" ]]; then + logrotate -f "$LOGROTATE_CONF" 2>/dev/null && log " logrotate ran OK" || warn " logrotate failed (non-fatal)" + else + log " No logrotate config found at $LOGROTATE_CONF โ€” skipping" + fi +else + log " logrotate not available โ€” using manual rotation" +fi + +# โ”€โ”€ 2. Compress logs older than 1 day โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +log "Step 2: Compress logs older than 1 day" +if [[ -d "$LOG_DIR" ]]; then + while IFS= read -r -d '' f; do + if gzip -f "$f" 2>/dev/null; then + log " Compressed: $f" + else + warn " Failed to compress: $f" + fi + done < <(find "$LOG_DIR" -type f \( -name "*.log" -o -name "*.out" \) -mtime +1 ! -name "*.gz" -print0 2>/dev/null) +else + log " Log directory not found: $LOG_DIR โ€” skipping" +fi + +# โ”€โ”€ 3. Delete logs older than LOG_MAX_DAYS days โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +log "Step 3: Delete logs older than ${LOG_MAX_DAYS} days" +if [[ -d "$LOG_DIR" ]]; then + delete_found "$LOG_DIR" -type f -mtime "+${LOG_MAX_DAYS}" +fi + +# โ”€โ”€ 4. journalctl vacuum โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +log "Step 4: journalctl vacuum (keep up to ${JOURNAL_VACUUM_MB}MB)" +if command -v journalctl >/dev/null 2>&1; then + journalctl --vacuum-size="${JOURNAL_VACUUM_MB}M" 2>&1 | grep -v "^$" | while IFS= read -r line; do + log " [journal] $line" + done || warn " journalctl vacuum failed (non-fatal)" +else + log " journalctl not available โ€” skipping" +fi + +# โ”€โ”€ 5. Clear npm cache โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +log "Step 5: Clear npm cache" +if command -v npm >/dev/null 2>&1; then + npm cache clean --force 2>&1 | tail -1 | while IFS= read -r line; do log " [npm] $line"; done || true + log " npm cache cleared" +else + log " npm not found โ€” skipping" +fi + +# โ”€โ”€ 6. Clear temp files โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +log "Step 6: Clear temp files older than ${TEMP_MAX_DAYS} days" +if [[ -d "$DATA_DIR" ]]; then + delete_found "$DATA_DIR" -type f -name "*.tmp.*" -mtime "+${TEMP_MAX_DAYS}" +fi +# Clear /tmp files with our app name (best-effort; ignore errors) +while IFS= read -r -d '' f; do + rm -f "$f" 2>/dev/null && log " Deleted /tmp file: $f" || true +done < <(find /tmp -type f -name "runewager-*" -mtime "+${TEMP_MAX_DAYS}" -print0 2>/dev/null) + +# โ”€โ”€ 7. Delete old runtime state snapshots older than SNAPSHOT_MAX_DAYS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +log "Step 7: Delete runtime state snapshots older than ${SNAPSHOT_MAX_DAYS} days" +if [[ -d "$BACKUP_DIR" ]]; then + delete_found "$BACKUP_DIR" -type f -name "runtime-state-*.json" -mtime "+${SNAPSHOT_MAX_DAYS}" +else + log " Backup dir not found: $BACKUP_DIR โ€” skipping" +fi + +# โ”€โ”€ 8. NEVER delete critical files โ€” safety check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Verifies that the cleanup did not accidentally remove key runtime files. +# Sets CRITICAL_OK=false (and emits a warning) for every file that is missing. +log "Step 8: Safety check โ€” verifying critical files are intact" +CRITICAL_OK=true +for f in "${APP_DIR}/.env" "${DATA_DIR}/runtime-state.json"; do + if [[ -f "$f" ]]; then + log " OK: $f" + else + warn " MISSING: $f โ€” expected file not found after cleanup" + CRITICAL_OK=false + fi +done +if [[ "$CRITICAL_OK" != "true" ]]; then + warn "โš ๏ธ One or more critical files are missing โ€” review the output above" +fi + +# โ”€โ”€ 9. Disk usage summary โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +log "Step 9: Disk usage summary" +df -h / 2>/dev/null | tail -1 | while IFS= read -r line; do log " / : $line"; done +if [[ -d "$APP_DIR" ]]; then + du -sh "$APP_DIR" 2>/dev/null | while IFS= read -r line; do log " App dir: $line"; done +fi +if [[ -d "$LOG_DIR" ]]; then + du -sh "$LOG_DIR" 2>/dev/null | while IFS= read -r line; do log " Logs: $line"; done +fi + +log "Disk protection complete."