From 09d2c58409fabeb5727044ba0130545e225b89c9 Mon Sep 17 00:00:00 2001 From: GambleCodez Affiliates Date: Tue, 24 Feb 2026 22:56:32 -0600 Subject: [PATCH] Audit menu callbacks, unify admin toggle, and fix group-only tip testing --- .env.example | 6 + .gitignore | 55 +- Dockerfile | 27 - LICENSE | 21 + README.md | 9 +- SECURITY.md | 21 + deploy.sh | 11 + index.js | 1658 ++++++++++++++++++++++++++++++++------ prod-run.sh | 238 +++++- runewager.service | 10 +- scripts/self-diagnose.sh | 358 +++++--- 11 files changed, 2005 insertions(+), 409 deletions(-) delete mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 SECURITY.md diff --git a/.env.example b/.env.example index f0b547c..591dede 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,12 @@ DEVICE=vps ADMIN_IDS=YOUR_TELEGRAM_USER_ID # Bot token from @BotFather โ€” BOT_TOKEN is the canonical name BOT_TOKEN= +# WebApp init-data HMAC key used to verify Telegram WebApp payload signatures. +# Use a secret 32-byte value (hex or base64), for example: +# openssl rand -hex 32 +# openssl rand -base64 32 +# Keep this value private and never commit real secrets to git. +WEBAPP_HMAC_KEY= # Legacy alias โ€” only needed if you used TELEGRAM_BOT_TOKEN before; BOT_TOKEN takes priority TELEGRAM_BOT_TOKEN= DISCORD_CODE_GENERATION_IMAGE_URL=https://raw.githubusercontent.com/gamblecodezcom/Runewager/main/images/discord_code_generation.png diff --git a/.gitignore b/.gitignore index d6e1430..f6d03df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,38 +1,33 @@ -node_modules/ - -# Logs -logs/* -!logs/.gitkeep -*.log - # Environment files .env -*.env -**/*.env .env.* -env -env.* - -# Runtime JSON state (do not commit) -/data/*.json -/users.json -/giveaways.json -/promo.json -/env.json - -# Legacy runtime state -bot_state.json +*.env -# Generated runtime backups -data/backups/* -!data/backups/.gitkeep +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* -# Service log artifacts -runewager-*.log -crash.log +# Node modules +node_modules/ -# Runtime admin event log (append-only, not for version control) -data/admin-events.log +# Temp + cache +.tmp/ +.cache/ +dist/ +build/ -# Rollback marker (runtime artifact) +# System files +.DS_Store +Thumbs.db .last_rollback +data/admin-events.log +data/*.json +data/backups/* +users.json +giveaways.json +promo.json +env.json +analytics*.json diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index fead93a..0000000 --- a/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -FROM node:20-slim - -WORKDIR /app - -ENV NODE_ENV=production - -# Install curl for healthcheck (wget not included in node:20-slim) -RUN apt-get update -qq && apt-get install -y --no-install-recommends curl \ - && rm -rf /var/lib/apt/lists/* - -# Create non-root user before installing deps so ownership is correct from the start -RUN useradd --system --create-home --uid 10001 runewager - -COPY package*.json ./ - -# Install production deps, then fix ownership so the non-root user can read them -RUN npm ci --omit=dev \ - && chown -R runewager:runewager /app - -COPY --chown=runewager:runewager . . - -USER runewager - -HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD curl -fsS "http://127.0.0.1:${PORT:-3000}/health" >/dev/null || exit 1 - -CMD ["node", "index.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b95e76e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Runewager + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2080bfb..5ad007c 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,9 @@ npm install npm start ``` -## Docker +## Deployment -```bash -docker build -t runewager-bot . -docker run -d --env-file .env --name runewager runewager-bot -``` +Systemd is the only supported production deployment path (`prod-run.sh` + `runewager.service`). ## Environment Variables @@ -60,7 +57,7 @@ See `.env.example` for all variables. Required: `BOT_TOKEN`, `ADMIN_IDS`. ## Health Check -The bot runs an HTTP server on `PORT` (default: 3000). +The bot runs a local health server on `PORT` (default: 3000), with HTTPS when TLS cert/key are configured and HTTP fallback if not. - `GET /health` returns runtime health metadata (uptime, node version, users, pending bonus count, last persist timestamp). - `GET /metrics` returns Prometheus-style counters/gauges for core bonus and user metrics. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..626b945 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +We provide security updates for the latest `main` branch and the latest production deployment. + +## Reporting a Vulnerability + +Please report vulnerabilities privately to the maintainers. + +- Preferred: open a private security advisory on GitHub. +- Alternative: contact project maintainers via direct admin channel. + +When reporting, include: + +- A clear description of the issue +- Steps to reproduce +- Potential impact +- Suggested mitigation (if available) + +We aim to acknowledge reports within 72 hours and provide remediation guidance as quickly as possible. diff --git a/deploy.sh b/deploy.sh index ed03293..c08825d 100755 --- a/deploy.sh +++ b/deploy.sh @@ -189,6 +189,17 @@ NPM_CMD="npm install --omit=dev" NPM_OUT="" if NPM_OUT=$(${NPM_CMD} 2>&1); then say "Dependencies installed." + mkdir -p "$PROJECT_DIR/data" "$PROJECT_DIR/data/backups" "$PROJECT_DIR/logs" + touch "$PROJECT_DIR/data/sshv-sessions.json" "$PROJECT_DIR/data/admin-events.log" + + if id -u "$APP_NAME" >/dev/null 2>&1; then + chown -R "$APP_NAME:$APP_NAME" "$PROJECT_DIR/data" "$PROJECT_DIR/logs" || true + [[ -f "$PROJECT_DIR/.env" ]] && chown "$APP_NAME:$APP_NAME" "$PROJECT_DIR/.env" || true + fi + + chmod 0750 "$PROJECT_DIR/data" "$PROJECT_DIR/data/backups" "$PROJECT_DIR/logs" || true + chmod 0640 "$PROJECT_DIR/data/sshv-sessions.json" "$PROJECT_DIR/data/admin-events.log" || true + [[ -f "$PROJECT_DIR/.env" ]] && chmod 0600 "$PROJECT_DIR/.env" || true else warn "npm install failed โ€” restarting bot on existing node_modules" warn "npm output: $NPM_OUT" diff --git a/index.js b/index.js index 8b2b7ab..1e923fc 100644 --- a/index.js +++ b/index.js @@ -1,11 +1,11 @@ require('dotenv').config(); const { Telegraf, Markup } = require('telegraf'); -const http = require('http'); +const https = require('https'); const fs = require('fs'); const path = require('path'); const os = require('os'); const crypto = require('crypto'); -const { execFile, spawn } = require('child_process'); +const { exec, execFile, spawn } = require('child_process'); // ========================= // Config @@ -76,7 +76,8 @@ const IMG_INTRO_GIF = process.env.INTRO_GIF_URL || path.join(IMG_DIR, 'runewager async function sendPhoto(ctx, imgSrc, caption, extra = {}) { try { const isUrl = imgSrc.startsWith('https://') || imgSrc.startsWith('http://'); - const source = isUrl ? imgSrc : { source: fs.createReadStream(imgSrc) }; + const safeImgPath = isUrl ? null : validateSafePath(imgSrc, IMG_DIR); + const source = isUrl ? imgSrc : { source: fs.createReadStream(safeImgPath) }; await ctx.replyWithPhoto(source, { caption, ...extra }); } catch (e) { logEvent('warn', 'sendPhoto failed, falling back to text', { imgSrc, error: e.message }); @@ -91,7 +92,8 @@ async function sendPhoto(ctx, imgSrc, caption, extra = {}) { async function sendIntroGif(ctx, caption, extra = {}) { try { const isUrl = IMG_INTRO_GIF.startsWith('https://') || IMG_INTRO_GIF.startsWith('http://'); - const source = isUrl ? IMG_INTRO_GIF : { source: fs.createReadStream(IMG_INTRO_GIF) }; + const safeGifPath = isUrl ? null : validateSafePath(IMG_INTRO_GIF, IMG_DIR); + const source = isUrl ? IMG_INTRO_GIF : { source: fs.createReadStream(safeGifPath) }; await ctx.replyWithAnimation(source, { caption, ...extra }); } catch (e) { logEvent('warn', 'sendIntroGif failed, falling back to text', { error: e.message }); @@ -167,21 +169,21 @@ const giveawayStore = { // Tips Store โ€” rotating silent group posts // ========================= const DEFAULT_TIPS_LIST = [ - { id: 1, text: '๐Ÿ’ก *Did you know?* Runewager gives *instant crypto redemptions* on all sweepstakes prizes. No delays, no stress โ€” just claim and flex.', enabled: true }, - { id: 2, text: '๐Ÿ‘‹ New here? Tap /start in the bot to unlock your *FREE SC bonuses*, link your account, and join our giveaways.', enabled: true }, - { id: 3, text: '๐Ÿ† Runewager\'s weekly leaderboard is *LIVE* โ€” climb the ranks, earn bragging rights, and win real prizes. All 100% free to play.', enabled: true }, - { id: 4, text: '๐Ÿ”— *Pro tip:* Linking your Runewager username in the bot unlocks bonuses, giveaways, and faster support.', enabled: true }, + { id: 1, text: '๐Ÿ’ก Did you know? Runewager gives instant crypto redemptions on all sweepstakes prizes. No delays, no stress โ€” just claim and flex.', enabled: true }, + { id: 2, text: '๐Ÿ‘‹ New here? Tap /start in the bot to unlock your FREE SC bonuses, link your account, and join our giveaways.', enabled: true }, + { id: 3, text: '๐Ÿ† Runewager\'s weekly leaderboard is LIVE โ€” climb the ranks, earn bragging rights, and win real prizes. All 100% free to play.', enabled: true }, + { id: 4, text: '๐Ÿ”— Pro tip: Linking your Runewager username in the bot unlocks bonuses, giveaways, and faster support.', enabled: true }, { id: 5, text: '๐ŸŽ Want more SC? Join our Telegram channel for surprise drops, flash bonuses, and exclusive codes.', enabled: true }, - { id: 6, text: '๐ŸŽฎ Runewager features top providers like *Hacksaw*, *BGaming*, *Betsoft*, and more โ€” all sweepstakesโ€‘safe and free to play.', enabled: true }, + { id: 6, text: '๐ŸŽฎ Runewager features top providers like Hacksaw, BGaming, Betsoft, and more โ€” all sweepstakesโ€‘safe and free to play.', enabled: true }, { id: 7, text: '๐Ÿ”’ Make sure you\'re Discordโ€‘verified in the bot โ€” it unlocks extra rewards and keeps your account secure.', enabled: true }, - { id: 8, text: '๐ŸŽ‰ Tap *Giveaways* in the bot menu to join our SC giveaways with automatic eligibility checks.', enabled: true }, + { id: 8, text: '๐ŸŽ‰ Tap Giveaways in the bot menu to join our SC giveaways with automatic eligibility checks.', enabled: true }, { id: 9, text: '๐ŸŒ Runewager accepts players worldwide โ€” no region blocks, no deposit requirements, just pure sweepstakes fun.', enabled: true }, { id: 10, text: '๐Ÿ’ฐ Your bonuses refresh often โ€” check the bot for new user bonuses, 30 SC rewards, and seasonal promos.', enabled: true }, { id: 11, text: '๐Ÿค Want to help the community grow? Share the bot link with a friend โ€” more players means bigger giveaways.', enabled: true }, - { id: 12, text: 'โ“ If you ever get stuck, tap *Help / Commands* in the bot for a full guide, tooltips, and troubleshooting.', enabled: true }, - { id: 13, text: '๐Ÿ“ฑ Runewager\'s miniโ€‘app loads instantly inside Telegram โ€” or switch to browser mode in *Settings* if you prefer.', enabled: true }, + { id: 12, text: 'โ“ If you ever get stuck, tap Help / Commands in the bot for a full guide, tooltips, and troubleshooting.', enabled: true }, + { id: 13, text: '๐Ÿ“ฑ Runewager\'s miniโ€‘app loads instantly inside Telegram โ€” or switch to browser mode in Settings if you prefer.', enabled: true }, { id: 14, text: '๐Ÿ‘€ Stay active in the group โ€” admins drop surprise SC codes and leaderboard boosts at random times.', enabled: true }, - { id: 15, text: '๐Ÿ†“ Reminder: Runewager is *100% free to play*. No deposits, no wagering, no risk โ€” just sweepstakes fun and instant crypto prizes.', enabled: true }, + { id: 15, text: '๐Ÿ†“ Reminder: Runewager is 100% free to play. No deposits, no wagering, no risk โ€” just sweepstakes fun and instant crypto prizes.', enabled: true }, ]; const tipsStore = { @@ -190,6 +192,7 @@ const tipsStore = { intervalHours: 4, targetGroup: TIPS_GROUP, nextTipId: 16, + lastSentTipId: null, }; let tipsTimerRef = null; @@ -199,6 +202,34 @@ const promoHistoryFile = path.join(dataDir, 'promo-history.json'); const analyticsFile = path.join(dataDir, 'analytics.json'); const dashboardFile = path.join(dataDir, 'dashboard.json'); const runtimeStateFile = path.join(dataDir, 'runtime-state.json'); +const helpfulMessagesFile = path.join(dataDir, 'helpful_messages.json'); +const sshvSessionsFile = path.join(dataDir, 'sshv-sessions.json'); + + +function normalizeHelpfulMessageEntry(entry, idHint = null) { + if (!entry || typeof entry !== 'object') return null; + const text = typeof entry.text === 'string' ? entry.text.trim() : ''; + if (!text) return null; + const parsedId = Number(entry.id); + const id = Number.isFinite(parsedId) && parsedId > 0 ? parsedId : idHint; + return { id: Number(id), text, enabled: entry.enabled !== false }; +} + +function saveHelpfulMessages() { + const snapshot = tipsStore.tips.map((tip) => ({ text: tip.text, enabled: tip.enabled !== false })); + saveJson(helpfulMessagesFile, snapshot); +} + +function loadHelpfulMessages() { + const raw = loadJson(helpfulMessagesFile, null); + if (!Array.isArray(raw)) return; + const normalized = raw + .map((entry, idx) => normalizeHelpfulMessageEntry(entry, idx + 1)) + .filter(Boolean); + if (!normalized.length) return; + tipsStore.tips = normalized; + tipsStore.nextTipId = normalized.reduce((maxId, t) => Math.max(maxId, t.id), 0) + 1; +} const backupDir = path.join(dataDir, 'backups'); const MAX_ANALYTICS_EVENTS = Number(process.env.MAX_ANALYTICS_EVENTS || 2000); const MAX_ONBOARDING_STEPS_HISTORY = Number(process.env.MAX_ONBOARDING_STEPS_HISTORY || 500); @@ -212,6 +243,12 @@ const userMutationQueue = new Map(); // per-user promise chain for atomic update const processStartedAt = Date.now(); let lastPersistAt = 0; +// Admin /sshv lightweight session store (one active session per admin) +const SSHV_SESSION_TTL_MS = 10 * 60 * 1000; +const SSHV_DEFAULT_CWD = '/root'; +const SSHV_MAX_BUFFER_LINES = 200; +const sshvSessions = new Map(); // adminId -> {adminId, active, cwd, buffer, lastCommand, locked, createdAt, lastActivity, runningProcess, editorMode} + // Absolute path to the project root (same directory as this file) const PROJECT_DIR = path.resolve(__dirname); @@ -249,6 +286,98 @@ const NON_USERNAME_WORDS = new Set([ 'thank', 'please', 'go', 'home', 'main', 'more', 'info', 'test', 'bye', 'nope', ]); +// Slash commands implemented in this file. Used for unknown-command fallback. +const REGISTERED_COMMANDS = new Set([ + 'A', + 'a', + 'admin', + 'admin_backup', + 'admin_log', + 'admin_notify', + 'affiliate', + 'announce', + 'approve_group', + 'bonus', + 'bonusstatus', + 'boost_referrals', + 'boostmeter', + 'broadcast_failed', + 'broadcast_retry', + 'bugreport', + 'bugreports', + 'cancel', + 'checkin', + 'claim_history', + 'commands', + 'deploy', + 'deploy_status', + 'discord', + 'discord_confirm', + 'discord_stats', + 'eligible', + 'exportbugs', + 'fixaccount', + 'funnel', + 'giveaway', + 'gw_graphic', + 'gw_pause', + 'gw_resume', + 'gwhistory', + 'health', + 'help', + 'join', + 'language', + 'leaderboard', + 'leaderboard_weekly', + 'link', + 'linkaccount', + 'linkrunewager', + 'list_groups', + 'logs', + 'menu', + 'mygiveaways', + 'off', + 'on', + 'pick_winner', + 'play', + 'profile', + 'promo', + 'promo_cooldown', + 'promocheck', + 'referral', + 'refreshuser', + 'resolvebug', + 'scan_eligibility', + 'setpromo', + 'settings', + 'signup', + 'sshv', + 'start_giveaway', + 'startapp', + 'status', + 'stuck', + 'support', + 'start', + 't', + 'testall', + 'testgiveaway', + 'tipadd', + 'tipedit', + 'tiplist', + 'tipremove', + 'tips', + 'tipsettings', + 'tiptest', + 'tiptoggle', + 'top', + 'tp', + 'unapprove_group', + 'version', + 'wager30_admin', + 'walkthrough', + 'whois', +]); + const WAGER_BONUS_RULES_TEXT = '๐ŸŽฏ *GambleCodez 30 SC Bonus โ€” Full Details*\n\n' + `${AFFILIATE_REMINDER_TEXT}\n\n` @@ -511,7 +640,7 @@ function loadRuntimeState() { if (raw.tipsStore) { if (Array.isArray(raw.tipsStore.tips) && raw.tipsStore.tips.length > 0) { - tipsStore.tips = raw.tipsStore.tips; + tipsStore.tips = raw.tipsStore.tips.map((t, idx) => normalizeHelpfulMessageEntry(t, idx + 1)).filter(Boolean); } if (typeof raw.tipsStore.systemEnabled === 'boolean') tipsStore.systemEnabled = raw.tipsStore.systemEnabled; if (typeof raw.tipsStore.intervalHours === 'number' && raw.tipsStore.intervalHours > 0) { @@ -522,6 +651,7 @@ function loadRuntimeState() { } if (typeof raw.tipsStore.nextTipId === 'number') tipsStore.nextTipId = raw.tipsStore.nextTipId; } + tipsStore.nextTipId = Math.max(tipsStore.nextTipId, tipsStore.tips.reduce((m, t) => Math.max(m, Number(t.id) || 0), 0) + 1); } function persistRuntimeState() { @@ -584,6 +714,8 @@ function createDefaultUser(user) { mainMenuChatId: null, adminMenuMsgId: null, adminMenuChatId: null, + ephemeralBonusMsgId: null, + ephemeralBonusChatId: null, referralCount: 0, lastReferralAt: 0, boostExpiresAt: 0, // ms timestamp; if > now, winner gets 2ร— SC @@ -692,25 +824,27 @@ function isAdmin(ctx) { return ADMIN_IDS.includes(Number(ctx.from.id)); } +function persistAdminMode(user, enabled) { + user.adminModeOn = enabled; + persistRuntimeState(); +} + +async function refreshAdminMenuHeader(ctx, user) { + const chatType = ctx.chat && ctx.chat.type ? ctx.chat.type : null; + if (chatType !== 'private') return; + await sendPersistentAdminMenu(ctx, user); +} + function requireAdmin(ctx) { - const cmd = ctx.message ? String(ctx.message.text || '').split(' ')[0].replace('/', '') : 'command'; + const cmd = ctx.message + ? String(ctx.message.text || '').split(' ')[0].replace('/', '') + : (ctx.callbackQuery && ctx.callbackQuery.data) ? String(ctx.callbackQuery.data) : 'command'; if (!isAdmin(ctx)) { sendCommandError(ctx, { command: cmd, - reason: 'โ›” This command is for admins only.', - howToUse: 'N/A โ€” admin-only command', - example: 'N/A', - }).catch(() => {}); - return false; - } - // Admin is in user-mode (/on) โ€” treat exactly as a regular user - const user = getUser(ctx); - if (user.adminModeOn) { - sendCommandError(ctx, { - command: cmd, - reason: "โ›” Admin-only command. You're currently in user mode (/on).", - howToUse: 'Use /off to switch back to admin mode, then retry.', - example: '/off', + reason: 'Admin only', + howToUse: 'This command is restricted to administrators', + example: '/help', }).catch(() => {}); return false; } @@ -735,6 +869,474 @@ async function sendCommandError(ctx, { command, reason, howToUse, example }) { }); } + + +function resolveTlsCertPathIfAllowed(inputPath) { + const allowedDirs = [ + PROJECT_DIR, + path.join(PROJECT_DIR, 'certs'), + '/etc/ssl', + '/etc/letsencrypt', + ]; + for (const baseDir of allowedDirs) { + try { + return validateSafePath(inputPath, baseDir); + } catch (_) { + // try next allowed directory + } + } + throw new Error('TLS path is outside allowed directories'); +} + +function isHealthHttpsConfigured() { + const tlsKeyPath = process.env.HTTPS_KEY_PATH; + const tlsCertPath = process.env.HTTPS_CERT_PATH; + if (!tlsKeyPath || !tlsCertPath) return false; + try { + resolveTlsCertPathIfAllowed(tlsKeyPath); + resolveTlsCertPathIfAllowed(tlsCertPath); + return true; + } catch (_) { + return false; + } +} + +function requestHealthPayload() { + const port = Number(process.env.PORT || 3000); + const useHttps = isHealthHttpsConfigured(); + const client = useHttps ? require('https') : require('http'); + return new Promise((resolve, reject) => { + const req = client.request({ + hostname: '127.0.0.1', + port, + path: '/health', + method: 'GET', + timeout: 5000, + ...(useHttps ? { rejectUnauthorized: false } : {}), + }, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => resolve({ data, statusCode: res.statusCode, protocol: useHttps ? 'https' : 'http' })); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); }); + req.end(); + }); +} + +function normalizeSshvBufferLines(bufferValue) { + if (Array.isArray(bufferValue)) { + return bufferValue.map((line) => escapeMarkdownFull(String(line))).slice(-SSHV_MAX_BUFFER_LINES); + } + if (typeof bufferValue === 'string') { + return bufferValue + .split(/\r?\n/) + .filter((line) => line.length > 0) + .map((line) => escapeMarkdownFull(line)) + .slice(-SSHV_MAX_BUFFER_LINES); + } + return []; +} + +function stringifySshvBuffer(bufferValue) { + return normalizeSshvBufferLines(bufferValue).join('\n'); +} + +function persistSshvSessions() { + const entries = []; + for (const [adminId, session] of sshvSessions.entries()) { + if (!ADMIN_IDS.includes(Number(adminId))) continue; + entries.push({ + adminId: Number(adminId), + active: true, + cwd: session.cwd || SSHV_DEFAULT_CWD, + buffer: normalizeSshvBufferLines(session.buffer), + lastCommand: String(session.lastCommand || '').slice(0, 200), + locked: Boolean(session.locked), + createdAt: Number(session.createdAt || Date.now()), + lastActivity: Number(session.lastActivity || Date.now()), + editorMode: session.editorMode + ? { + filePath: String(session.editorMode.filePath || ''), + fileArg: String(session.editorMode.fileArg || ''), + draft: typeof session.editorMode.draft === 'string' + ? session.editorMode.draft.slice(0, 20000) + : null, + } + : null, + }); + } + saveJson(sshvSessionsFile, entries); +} + +function validateAndFixSshvSession(user, session, { onStartup = false } = {}) { + const corrections = []; + if (!session || !user) return corrections; + + if (!session.cwd || typeof session.cwd !== 'string') { + session.cwd = SSHV_DEFAULT_CWD; + corrections.push('cwd_reset_default'); + } else { + const resolved = path.resolve(session.cwd); + if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) { + session.cwd = SSHV_DEFAULT_CWD; + corrections.push('cwd_invalid_reset'); + } else { + session.cwd = resolved; + } + } + + if (session.editorMode) { + const filePath = session.editorMode.filePath; + if (!filePath || typeof filePath !== 'string') { + session.editorMode = null; + corrections.push('editor_invalid_cleared'); + } else { + const resolved = path.resolve(filePath); + if (!fs.existsSync(path.dirname(resolved))) { + session.editorMode = null; + corrections.push('editor_path_invalid_cleared'); + } else { + session.editorMode.filePath = resolved; + } + } + } + + if (typeof session.locked !== 'boolean') { + session.locked = false; + corrections.push('lock_normalized'); + } + if (onStartup && session.locked) { + session.locked = false; + corrections.push('lock_cleared_after_restart'); + adminLog('sshv_unlock_after_restart', { by: user.id }); + } + + const normalizedBuffer = stringifySshvBuffer(session.buffer); + if (session.buffer !== normalizedBuffer) corrections.push('buffer_normalized'); + session.buffer = normalizedBuffer; + + if (!session.lastActivity) { + session.lastActivity = Date.now(); + corrections.push('last_activity_set'); + } + if (!session.createdAt) { + session.createdAt = Date.now(); + corrections.push('created_at_set'); + } + + if (user.pendingAction && String(user.pendingAction.type || '').startsWith('await_sshv_')) { + if (session.editorMode && user.pendingAction.type !== 'await_sshv_editor_content') { + user.pendingAction = { type: 'await_sshv_editor_content' }; + corrections.push('pending_set_editor'); + } + if (!session.editorMode && user.pendingAction.type === 'await_sshv_editor_content') { + user.pendingAction = { type: 'await_sshv_command' }; + corrections.push('pending_editor_cleared'); + } + if (onStartup && user.pendingAction.type === 'await_sshv_command') { + user.pendingAction = null; + corrections.push('pending_command_cleared_after_restart'); + adminLog('sshv_pending_cleared_after_restart', { by: user.id }); + } + } + + if (corrections.length > 0) { + adminLog('sshv_state_fix', { by: user.id, corrections }); + } + return corrections; +} + +function loadSshvSessions() { + const raw = loadJson(sshvSessionsFile, []); + if (!Array.isArray(raw)) return; + for (const item of raw) { + const adminId = Number(item && item.adminId); + if (!ADMIN_IDS.includes(adminId)) continue; + const user = userStore.get(adminId); + if (!user) continue; + const session = { + adminId, + active: true, + cwd: item.cwd || SSHV_DEFAULT_CWD, + buffer: stringifySshvBuffer(item.buffer), + lastCommand: String(item.lastCommand || ''), + locked: Boolean(item.locked), + createdAt: Number(item.createdAt || Date.now()), + lastActivity: Number(item.lastActivity || Date.now()), + runningProcess: null, + editorMode: item.editorMode && typeof item.editorMode === 'object' + ? { + filePath: String(item.editorMode.filePath || ''), + fileArg: String(item.editorMode.fileArg || ''), + draft: typeof item.editorMode.draft === 'string' ? item.editorMode.draft : null, + } + : null, + restoredAfterRestart: true, + }; + try { + validateAndFixSshvSession(user, session, { onStartup: true }); + sshvSessions.set(adminId, session); + } catch (_) { + sshvSessions.delete(adminId); + const fresh = getSshvSession(adminId, { createIfMissing: true }); + validateAndFixSshvSession(user, fresh, { onStartup: true }); + } + } + persistSshvSessions(); + + for (const adminId of ADMIN_IDS) { + const user = userStore.get(Number(adminId)); + if (!user || !user.pendingAction) continue; + if (String(user.pendingAction.type || '').startsWith('await_sshv_') && !sshvSessions.has(Number(adminId))) { + user.pendingAction = null; + adminLog('sshv_pending_cleared_after_restart', { by: Number(adminId) }); + } + } +} + +function refreshSshvSessionForUser(user) { + const existing = getSshvSession(user.id); + if (existing) validateAndFixSshvSession(user, existing, { onStartup: false }); + destroySshvSession(user.id); + user.pendingAction = null; + persistSshvSessions(); + const session = getSshvSession(user.id, { createIfMissing: true }); + validateAndFixSshvSession(user, session, { onStartup: false }); + adminLog('sshv_refresh', { by: user.id }); + persistSshvSessions(); + return session; +} + +function getSshvSession(adminId, { createIfMissing = false } = {}) { + const existing = sshvSessions.get(adminId); + if (existing) { + if (Date.now() - (existing.lastActivity || existing.createdAt || 0) > SSHV_SESSION_TTL_MS) { + if (existing.runningProcess && !existing.runningProcess.killed) { + existing.runningProcess.kill('SIGTERM'); + } + sshvSessions.delete(adminId); + } else { + existing.lastActivity = Date.now(); + persistSshvSessions(); + return existing; + } + } + if (!createIfMissing) return null; + const session = { + adminId, + active: true, + cwd: SSHV_DEFAULT_CWD, + buffer: '', + lastCommand: '', + locked: false, + createdAt: Date.now(), + lastActivity: Date.now(), + runningProcess: null, + editorMode: null, + }; + sshvSessions.set(adminId, session); + persistSshvSessions(); + return session; +} + +function destroySshvSession(adminId) { + const session = sshvSessions.get(adminId); + if (!session) return; + if (session.runningProcess && !session.runningProcess.killed) { + session.runningProcess.kill('SIGTERM'); + } + sshvSessions.delete(adminId); + persistSshvSessions(); +} + +function sshvOutputText(text) { + const raw = (text || '').trim() || '[no output yet]'; + const clipped = raw.length > 3000 ? `${raw.slice(0, 3000)}\n... (truncated)` : raw; + return clipped.replace(/```/g, "'''"); +} + +function sshvKeyboard(session) { + if (session.locked) { + return Markup.inlineKeyboard([ + [Markup.button.callback('๐Ÿ”“ Unlock', 'sshv_unlock'), Markup.button.callback('๐Ÿ”„ Refresh SSHV', 'sshv_refresh')], + [Markup.button.callback('๐Ÿšช Exit', 'sshv_exit')], + ]); + } + return Markup.inlineKeyboard([ + [Markup.button.callback('โ–ถ๏ธ Run', 'sshv_run_prompt'), Markup.button.callback('๐Ÿ›‘ Ctrl+C', 'sshv_ctrl_c'), Markup.button.callback('โธ Ctrl+Z', 'sshv_ctrl_z')], + [Markup.button.callback('๐Ÿ”’ Lock', 'sshv_lock'), Markup.button.callback('๐Ÿ”„ Refresh SSHV', 'sshv_refresh')], + [Markup.button.callback('๐Ÿšช Exit', 'sshv_exit')], + ]); +} + +async function renderSshvConsole(ctx, session, note = '') { + const text = [ + '๐Ÿ“Ÿ *Runewager VPS Console*', + `Path: \`${escapeMarkdownFull(session.cwd)}\``, + '', + 'Last Output:', + `\`\`\`\n${sshvOutputText(stringifySshvBuffer(session.buffer))}\n\`\`\``, + '', + note ? escapeMarkdownFull(note) : 'Enter command:', + '`reply with command text`', + ].join('\n'); + await ctx.reply(text, { parse_mode: 'MarkdownV2', ...sshvKeyboard(session) }); +} + +function parseCdTarget(commandText) { + const m = commandText.match(/^cd(?:\s+(.*))?$/); + if (!m) return null; + const target = (m[1] || '~').trim(); + return target || '~'; +} + +function resolveCdPath(currentCwd, target) { + if (target === '~') return SSHV_DEFAULT_CWD; + return path.resolve(currentCwd, target); +} + +function commandNeedsConfirmation(commandText) { + const text = String(commandText || ''); + const patterns = [ + /\b(rm|mv|dd|shred|mkfs|chown|chmod|sudo|shutdown|reboot|kill)\b/i, + /\b(curl|wget)\b[^\n]*\|[^\n]*\b(sh|bash|zsh)\b/i, + /\b(rm|dd|shred|mkfs)\b[^\n]*\|/i, + /(^|\s)>>?\s*\S+/i, + /(^|[^|])\|([^|]|$)/, + /[;`$(){}]/, + ]; + return patterns.some((re) => re.test(text)); +} + +function commandBlocked(commandText) { + const text = String(commandText || ''); + const blockedPatterns = [ + /(^|\s)&(\s|$)/, + /&&/, + /\|\|/, + /\bnohup\b/i, + /\bbg\b/i, + /\b(curl|wget)\b[^\n]*\|[^\n]*\b(sh|bash|zsh)\b/i, + ]; + return blockedPatterns.some((re) => re.test(text)); +} + +async function executeSshvCommand(ctx, user, session, commandText) { + const command = String(commandText || '').trim(); + if (!command) { + await sendCommandError(ctx, { + command: 'sshv', + reason: 'Command cannot be empty.', + howToUse: 'Tap Run, then send a shell command.', + example: 'ls -la', + }); + return; + } + if (session.locked) { + await ctx.reply('๐Ÿ”’ Console is locked. Tap Unlock first.', sshvKeyboard(session)); + return; + } + + const nanoMatch = command.match(/^nano\s+(.+)$/); + if (nanoMatch) { + const fileArg = nanoMatch[1].trim(); + const filePath = path.resolve(session.cwd, fileArg); + let fileBody = ''; + try { + fileBody = fs.readFileSync(filePath, 'utf8'); + } catch (_) { + fileBody = ''; + } + session.editorMode = { filePath, fileArg, draft: null }; + adminLog('sshv_editor_enter', { adminId: user.id, filePath }); + user.pendingAction = { type: 'await_sshv_editor_content' }; + persistSshvSessions(); + await ctx.reply( + [ + `๐Ÿ“ *Editor Mode*`, + `File: \`${escapeMarkdownFull(filePath)}\``, + '', + '*Current Content:*', + `\`\`\`\n${sshvOutputText(fileBody)}\n\`\`\``, + '', + 'Reply with the full new file content, then tap Save or Cancel.', + ].join('\n'), + { + parse_mode: 'MarkdownV2', + ...Markup.inlineKeyboard([ + [Markup.button.callback('๐Ÿ’พ Save', 'sshv_editor_save'), Markup.button.callback('โŒ Cancel', 'sshv_editor_cancel')], + ]), + }, + ); + return; + } + if (commandBlocked(command)) { + await sendCommandError(ctx, { + command: 'sshv', + reason: 'Background command operators are blocked.', + howToUse: 'Run foreground commands only.', + example: 'pm2 status', + }); + return; + } + if (commandNeedsConfirmation(command)) { + user.pendingAction = { type: 'await_sshv_danger_confirm', data: { command } }; + persistSshvSessions(); + await ctx.reply( + `โš ๏ธ Potentially dangerous command detected:\n\`${escapeMarkdownFull(command)}\`\n\nConfirm execution?`, + { + parse_mode: 'MarkdownV2', + ...Markup.inlineKeyboard([ + [Markup.button.callback('โœ… Confirm Run', 'sshv_confirm_run'), Markup.button.callback('โŒ Cancel', 'sshv_cancel_run')], + ]), + }, + ); + return; + } + + session.lastCommand = command; + adminLog('sshv_command_start', { adminId: user.id, command, cwd: session.cwd }); + session.lastActivity = Date.now(); + persistSshvSessions(); + + const cdTarget = parseCdTarget(command); + if (cdTarget !== null) { + const nextPath = resolveCdPath(session.cwd, cdTarget); + try { + const stat = fs.statSync(nextPath); + if (!stat.isDirectory()) throw new Error('Not a directory'); + session.cwd = nextPath; + session.buffer = `cwd updated to ${nextPath}`; + adminLog('sshv_cd_success', { adminId: user.id, target: cdTarget, cwd: nextPath }); + persistSshvSessions(); + await renderSshvConsole(ctx, session); + return; + } catch (e) { + session.buffer = `cd failed: ${e.message}`; + adminLog('sshv_cd_failure', { adminId: user.id, target: cdTarget, error: e.message }); + persistSshvSessions(); + await renderSshvConsole(ctx, session); + return; + } + } + + await ctx.reply(`โณ Running: \`${escapeMarkdownFull(command)}\``, { parse_mode: 'MarkdownV2' }); + await new Promise((resolve) => { + const child = exec(command, { cwd: session.cwd, timeout: 8000, maxBuffer: 2 * 1024 * 1024 }, async (err, stdout, stderr) => { + const out = `${stdout || ''}${stderr || ''}`.trim(); + session.buffer = out || (err ? err.message : '[no output]'); + session.runningProcess = null; + session.lastActivity = Date.now(); + adminLog('sshv_command_finish', { adminId: user.id, command, error: err ? err.message : null }); + persistSshvSessions(); + await renderSshvConsole(ctx, session, err ? 'Command finished with errors.' : 'Command finished.'); + resolve(); + }); + session.runningProcess = child; + }); +} + // ========================= // 30 SC Bonus helpers // ========================= @@ -901,7 +1503,7 @@ function mainMenuKeyboard(isAdminUser = false, page = 1, user = null) { if (isAdminUser) { rows.push([ Markup.button.callback('๐Ÿ”ง Admin Panel', 'menu_admin_tab'), - Markup.button.callback('๐Ÿ“Š Admin Dashboard', 'admin_dashboard_action'), + Markup.button.callback('๐Ÿ›  Admin Dashboard', 'admin_dashboard'), ]); } // Pagination: โ—€๏ธ Back to Page 1 @@ -939,7 +1541,7 @@ function mainMenuKeyboard(isAdminUser = false, page = 1, user = null) { Markup.button.callback('๐Ÿ’ฌ Join Group', 'menu_join_group'), ], // Admin shortcut โ€” only visible to admins - ...(isAdminUser ? [[Markup.button.callback('๐Ÿ›  Admin Dashboard', 'open_admin_dashboard')]] : []), + ...(isAdminUser ? [[Markup.button.callback('๐Ÿ›  Admin Dashboard', 'admin_dashboard')]] : []), // Pagination: Next โ–ถ๏ธ [Markup.button.callback('More Options โ–ถ๏ธ', 'menu_page_2')], ]; @@ -1118,6 +1720,8 @@ function adminPromoToolsKeyboard() { [Markup.button.callback('๐Ÿ”ข Edit Claim Limit', 'admin_edit_limit')], [Markup.button.callback('โธ Pause Promo', 'admin_pause'), Markup.button.callback('โ–ถ๏ธ Unpause', 'admin_unpause')], [Markup.button.callback('๐Ÿ“ข Broadcast Update', 'admin_broadcast')], + [Markup.button.callback('๐Ÿ“ฃ Announcements', 'admin_cmd_announce_start')], + [Markup.button.callback('๐Ÿ’ก Tips Manager', 'admin_cmd_tips_dashboard')], [Markup.button.callback('โฌ…๏ธ Admin Dashboard', 'open_admin_dashboard')], ]); } @@ -1133,13 +1737,16 @@ function adminUserToolsKeyboard() { ]); } -function adminSystemToolsKeyboard() { +function adminSystemToolsKeyboard(user = null) { + const toggleLabel = user && user.adminModeOn ? '๐ŸŸข Admin Mode: ON (tap to toggle)' : 'โšช Admin Mode: OFF (tap to toggle)'; return Markup.inlineKeyboard([ [Markup.button.callback('๐Ÿ›  Run TestAll', 'admin_cmd_testall')], + [Markup.button.callback('๐Ÿ“Ÿ VPS Console (/sshv)', 'sshv_open')], [Markup.button.callback('๐Ÿฉบ Health Check', 'admin_cmd_health')], [Markup.button.callback('๐Ÿ“ฆ Bot Version', 'admin_cmd_version')], + [Markup.button.callback('๐Ÿงช Tip Test (4h broadcast)', 'admin_cmd_tiptest')], [Markup.button.callback('๐Ÿ’พ Backup State', 'admin_backup_action')], - [Markup.button.callback('๐Ÿ”˜ Admin Mode On', 'admin_cmd_mode_on'), Markup.button.callback('โšช Admin Mode Off', 'admin_cmd_mode_off')], + [Markup.button.callback(toggleLabel, 'admin_cmd_mode_toggle')], [Markup.button.callback('โฌ…๏ธ Admin Dashboard', 'open_admin_dashboard')], ]); } @@ -1211,6 +1818,52 @@ async function sendAdminDashboard(ctx, page = 1) { ); } +async function replaceCallbackPanel(ctx, text, extra = {}) { + try { + await ctx.editMessageText(text, extra); + return; + } catch (_) { + // fallback to delete + send when source message is not editable + } + + if (ctx.callbackQuery && ctx.callbackQuery.message) { + try { + await ctx.telegram.deleteMessage(ctx.callbackQuery.message.chat.id, ctx.callbackQuery.message.message_id); + } catch (_) { /* ignore */ } + } + await ctx.reply(text, extra); +} + +function extractCallbackDataFromKeyboard(markup) { + const rows = (markup && markup.reply_markup && Array.isArray(markup.reply_markup.inline_keyboard)) + ? markup.reply_markup.inline_keyboard + : []; + const out = []; + for (const row of rows) { + for (const btn of row || []) { + if (btn && typeof btn.callback_data === 'string') out.push(btn.callback_data); + } + } + return out; +} + +function escapeHtml(text) { + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function formatTipForHtml(text) { + const t = String(text || '').trim(); + if (!t) return ''; + // If admin intentionally provided HTML tags, preserve them. + if (/[<][a-zA-Z/!][^>]*>/.test(t)) return t; + return escapeHtml(t); +} + async function sendSettingsMenu(ctx, user) { const s = user.settings; const lines = [ @@ -1230,7 +1883,7 @@ async function sendSettingsMenu(ctx, user) { async function sendMainMenu(ctx, user, page = 1) { const name = user.firstName ? `, ${user.firstName}` : ''; - const adminUser = ADMIN_IDS.includes(Number(user.id)); + const adminUser = ADMIN_IDS.includes(Number(user.id)) && user.adminModeOn; const statusLine = buildUserStatusLine(user); const pageLabel = page === 2 ? ' โ€” Page 2/2: Community & Extras' : ' โ€” Page 1/2: Main Actions'; @@ -1256,7 +1909,7 @@ function userMainMenuKeyboard(isAdminUser) { [Markup.button.callback('โ“ Help / Commands', 'pmenu_help')], ]; if (isAdminUser) { - rows.push([Markup.button.callback('๐Ÿ›  Admin Menu', 'pmenu_admin')]); + rows.push([Markup.button.callback('๐Ÿ›  Admin Dashboard', 'admin_dashboard')]); } return Markup.inlineKeyboard(rows); } @@ -1285,8 +1938,9 @@ function userMainMenuText(user) { async function sendPersistentUserMenu(ctx, user) { const chatId = ctx.chat ? ctx.chat.id : (ctx.callbackQuery && ctx.callbackQuery.message ? ctx.callbackQuery.message.chat.id : null); if (!chatId) return; - // Hide admin button when admin is in user-mode (/on) - const isAdminUser = ADMIN_IDS.includes(Number(user.id)) && !user.adminModeOn; + await deleteEphemeralBonusPrompt(ctx, user); + // Show admin button only when admin view is ON + const isAdminUser = ADMIN_IDS.includes(Number(user.id)) && user.adminModeOn; // Delete previous persistent menu message if it exists if (user.mainMenuMsgId && user.mainMenuChatId) { @@ -1297,6 +1951,15 @@ async function sendPersistentUserMenu(ctx, user) { user.mainMenuChatId = null; } + // Ensure admin menu is not lingering when user menu is shown + if (user.adminMenuMsgId && user.adminMenuChatId) { + try { + await ctx.telegram.deleteMessage(user.adminMenuChatId, user.adminMenuMsgId); + } catch (_) { /* ignore */ } + user.adminMenuMsgId = null; + user.adminMenuChatId = null; + } + const text = userMainMenuText(user); const keyboard = userMainMenuKeyboard(isAdminUser); const sent = await ctx.telegram.sendMessage(chatId, text, { parse_mode: 'Markdown', ...keyboard }); @@ -1305,21 +1968,20 @@ async function sendPersistentUserMenu(ctx, user) { } /** Keyboard for the persistent ADMIN MAIN MENU */ -function adminMainMenuKeyboard() { +function adminMainMenuKeyboard(user) { + const toggleLabel = user && user.adminModeOn ? '๐ŸŸข Admin Mode: ON (tap to toggle)' : 'โšช Admin Mode: OFF (tap to toggle)'; return Markup.inlineKeyboard([ - [Markup.button.callback('๐Ÿ“Ÿ System Status', 'pamenu_status')], - [Markup.button.callback('๐Ÿ“ˆ Stats Dashboard', 'pamenu_stats')], - [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('๐Ÿ‘ค Users & Stats', 'pamenu_stats')], + [Markup.button.callback('๐ŸŽ‰ Giveaways', 'pamenu_active_giveaways')], + [Markup.button.callback('๐Ÿ“ฃ Promo Tools', 'admin_cat_promo')], + [Markup.button.callback('โš™๏ธ System Tools', 'admin_cat_system')], + [Markup.button.callback(toggleLabel, 'admin_cmd_mode_toggle')], [Markup.button.callback('โ†ฉ Back to User Menu', 'pamenu_back_user')], ]); } /** Header text for the persistent ADMIN MAIN MENU */ -function adminMainMenuText() { +function adminMainMenuText(user) { const dash = buildDashboard(); const uptimeSec = Math.floor(process.uptime()); const uptimeH = Math.floor(uptimeSec / 3600); @@ -1327,9 +1989,11 @@ function adminMainMenuText() { const uptimeStr = uptimeH > 0 ? `${uptimeH}h ${uptimeM}m` : `${uptimeM}m`; const lastPersistAge = lastPersistAt ? Math.floor((Date.now() - lastPersistAt) / 1000) : 99999; const statusLight = lastPersistAge < 300 ? '๐ŸŸข' : lastPersistAge < 600 ? '๐ŸŸก' : '๐Ÿ”ด'; + const modeStatus = user && user.adminModeOn ? 'ON' : 'OFF'; return ( `๐Ÿ›  *Admin Menu*\n\n` - + `${statusLight} Bot: Uptime ${uptimeStr} ยท Users: ${dash.totalUsers} ยท Giveaways: ${dash.runningGiveaways} running` + + `${statusLight} Bot: Uptime ${uptimeStr} ยท Users: ${dash.totalUsers} ยท Giveaways: ${dash.runningGiveaways} running\n` + + `Mode Toggle Status: *${modeStatus}*` ); } @@ -1350,8 +2014,17 @@ async function sendPersistentAdminMenu(ctx, user) { user.adminMenuChatId = null; } - const text = adminMainMenuText(); - const keyboard = adminMainMenuKeyboard(); + // Ensure user menu is not lingering when admin menu is shown + if (user.mainMenuMsgId && user.mainMenuChatId) { + try { + await ctx.telegram.deleteMessage(user.mainMenuChatId, user.mainMenuMsgId); + } catch (_) { /* ignore */ } + user.mainMenuMsgId = null; + user.mainMenuChatId = null; + } + + const text = adminMainMenuText(user); + const keyboard = adminMainMenuKeyboard(user); const sent = await ctx.telegram.sendMessage(chatId, text, { parse_mode: 'Markdown', ...keyboard }); user.adminMenuMsgId = sent.message_id; user.adminMenuChatId = sent.chat.id; @@ -1480,6 +2153,62 @@ async function linkedUsernameError(ctx) { ); } + +async function deleteEphemeralBonusPrompt(ctx, user) { + const activePrompt = await runUserMutation(user.id, async () => { + if (!user.ephemeralBonusMsgId || !user.ephemeralBonusChatId) return null; + return { msgId: user.ephemeralBonusMsgId, chatId: user.ephemeralBonusChatId }; + }); + if (!activePrompt) return; + + try { + await ctx.telegram.deleteMessage(activePrompt.chatId, activePrompt.msgId); + } catch (_) { /* message may already be gone */ } + + await runUserMutation(user.id, async () => { + if (user.ephemeralBonusMsgId === activePrompt.msgId && user.ephemeralBonusChatId === activePrompt.chatId) { + user.ephemeralBonusMsgId = null; + user.ephemeralBonusChatId = null; + } + }); +} + +async function sendEphemeralBonusPrompt(ctx, user) { + await deleteEphemeralBonusPrompt(ctx, user); + + const sent = await ctx.reply( + `๐ŸŽ *Claim Your New User Bonus* + +Enter promo code *${promoStore.code}* on the Runewager affiliate page to claim your ${promoStore.amountSC} SC new user bonus!`, + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.url('๐ŸŽ Enter Promo Code (Mini App)', LINKS.miniAppClaim)], + [Markup.button.callback('โฌ…๏ธ Main Menu', 'to_main_menu')], + ]), + }, + ); + + await runUserMutation(user.id, async () => { + user.ephemeralBonusMsgId = sent.message_id; + user.ephemeralBonusChatId = sent.chat.id; + }); + + setTimeout(async () => { + try { + await ctx.telegram.deleteMessage(sent.chat.id, sent.message_id); + } catch (_) { /* best effort */ } + + await runUserMutation(user.id, async () => { + if (user.ephemeralBonusMsgId === sent.message_id && user.ephemeralBonusChatId === sent.chat.id) { + user.ephemeralBonusMsgId = null; + user.ephemeralBonusChatId = null; + } + }); + }, 15 * 1000); +} + + const adminEventsLogFile = path.join(dataDir, 'admin-events.log'); const ADMIN_LOG_CAP = 200; @@ -1517,17 +2246,8 @@ async function showOnboardingPrompt(ctx, user, step) { await sendLinkAccountPrompt(ctx, user); break; case 3: - // Promo claim - await ctx.reply( - `๐ŸŽ *Claim Your New User Bonus*\n\nEnter promo code *${promoStore.code}* on the Runewager affiliate page to claim your ${promoStore.amountSC} SC new user bonus!`, - { - parse_mode: 'Markdown', - ...Markup.inlineKeyboard([ - [Markup.button.url('๐ŸŽ Enter Promo Code (Mini App)', LINKS.miniAppClaim)], - [Markup.button.callback('โฌ…๏ธ Main Menu', 'to_main_menu')], - ]), - }, - ); + // Promo claim (auto-removes after 15s to keep chat organized) + await sendEphemeralBonusPrompt(ctx, user); break; case 4: // Join community channel + group @@ -1581,21 +2301,42 @@ function ensureDataDir() { if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); } +function validateSafePath(inputPath, baseDir) { + if (!inputPath || typeof inputPath !== 'string') { + throw new Error('Invalid path input'); + } + const normalizedInput = inputPath.replace(/\\/g, '/'); + if (normalizedInput.includes('../')) { + throw new Error('Path traversal detected'); + } + const safeBase = fs.realpathSync(baseDir); + const resolvedPath = path.resolve(safeBase, inputPath); + const realTarget = fs.existsSync(resolvedPath) + ? fs.realpathSync(resolvedPath) + : path.resolve(fs.realpathSync(path.dirname(resolvedPath)), path.basename(resolvedPath)); + if (!realTarget.startsWith(`${safeBase}${path.sep}`) && realTarget !== safeBase) { + throw new Error('Path outside allowed base directory'); + } + return realTarget; +} + function loadJson(filePath, fallback) { try { - if (!fs.existsSync(filePath)) return fallback; - return JSON.parse(fs.readFileSync(filePath, 'utf8')); + const safePath = validateSafePath(filePath, dataDir); + if (!fs.existsSync(safePath)) return fallback; + return JSON.parse(fs.readFileSync(safePath, 'utf8')); } catch (e) { return fallback; } } function writeFileAtomic(filePath, content) { - const dirPath = path.dirname(filePath); + const safeFilePath = validateSafePath(filePath, dataDir); + const dirPath = path.dirname(safeFilePath); if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true }); - const tempFile = `${filePath}.tmp.${process.pid}.${Date.now()}`; + const tempFile = `${safeFilePath}.tmp.${process.pid}.${Date.now()}`; fs.writeFileSync(tempFile, content); - fs.renameSync(tempFile, filePath); + fs.renameSync(tempFile, safeFilePath); } function saveJson(filePath, data) { @@ -1726,7 +2467,7 @@ async function reactToMessage(ctx, emoji) { // Verify Telegram Mini App initData following Telegram's spec exactly: // https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app -// secret_key = HMAC-SHA256(msg=BOT_TOKEN, key="WebAppData") +// secret_key = HMAC-SHA256(msg=BOT_TOKEN, key=WEBAPP_HMAC_KEY) // verify_hash = HMAC-SHA256(msg=data_check_string, key=secret_key) function verifyWebAppInitData(initDataRaw) { if (!initDataRaw || !BOT_TOKEN) return false; @@ -1738,8 +2479,10 @@ function verifyWebAppInitData(initDataRaw) { .sort(([a], [b]) => a.localeCompare(b)) .map(([k, v]) => `${k}=${v}`) .join('\n'); - // Step 1: secret_key = HMAC-SHA256(key="WebAppData", msg=BOT_TOKEN) - const secretKey = crypto.createHmac('sha256', 'WebAppData').update(BOT_TOKEN).digest(); + // Step 1: secret_key = HMAC-SHA256(key=WEBAPP_HMAC_KEY, msg=BOT_TOKEN) + const webAppHmacKey = process.env.WEBAPP_HMAC_KEY; + if (!webAppHmacKey) return false; + const secretKey = crypto.createHmac('sha256', webAppHmacKey).update(BOT_TOKEN).digest(); // Step 2: verify_hash = HMAC-SHA256(key=secret_key, msg=data_check_string) const calculated = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex'); // Constant-time comparison to prevent timing attacks @@ -1845,6 +2588,8 @@ function loadPersistentData() { Object.entries(promoHistory).forEach(([id, entries]) => promoHistoryStore.set(Number(id), entries)); Object.assign(analyticsStore, loadJson(analyticsFile, analyticsStore)); loadRuntimeState(); + loadHelpfulMessages(); + loadSshvSessions(); } function persistAnalytics() { @@ -1909,8 +2654,6 @@ async function configureBotSurface() { 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)' }, @@ -1922,11 +2665,11 @@ async function configureBotSurface() { { 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: 'on', description: 'Enable admin mode view' }, + { command: 'off', description: 'Disable admin mode view' }, { 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: 'sshv', description: 'Open lightweight VPS console' }, { 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' }, @@ -2126,8 +2869,8 @@ bot.command('settings', async (ctx) => { * Returns array of { text, buttons } objects. */ function buildHelpPages(user) { - // Hide admin page when admin is in user-mode (/on) โ€” treat as regular user - const adminUser = ADMIN_IDS.includes(Number(user.id)) && !user.adminModeOn; + // Admin help page is visible only when admin view is ON + const adminUser = ADMIN_IDS.includes(Number(user.id)) && user.adminModeOn; const tips = user.settings && user.settings.tooltipsEnabled; function navRow(page, total) { @@ -2352,16 +3095,14 @@ function buildHelpPages(user) { }, ]; - // โ”€โ”€ Page 6: Admin Command Reference (admins only โ€” hidden in user-mode) โ”€โ”€ + // โ”€โ”€ Page 6: Admin Command Reference (admins only โ€” shown when admin view is ON) โ”€โ”€ if (adminUser) { pages.push({ text: [ '๐Ÿ“– *Admin Help โ€” Page 6/6: Admin Commands*', '', '๐Ÿ›  DASHBOARD & NAVIGATION', - '/admin โ€” Persistent admin main menu', - '/admin_help โ€” This admin command reference', - '/admin_dashboard โ€” Live stats: users, promos, giveaways, errors', + '/admin โ€” Open the Admin Dashboard', '', '๐ŸŽฏ 30 SC BONUS MANAGEMENT', '/wager30_admin โ€” Full bonus admin panel (keyboard-driven)', @@ -2386,6 +3127,7 @@ function buildHelpPages(user) { '/setpromo โ€” Update the active promo message', '/boost_referrals [hours] [multiplier] โ€” Activate referral boost', '/admin_notify โ€” Targeted admin notification', + '/sshv โ€” Lightweight VPS console session (admin only, includes Refresh SSHV + session restore)', '/promo_cooldown โ€” Set promo cooldown period', '', '๐Ÿ‘ค USER MANAGEMENT', @@ -2393,8 +3135,8 @@ function buildHelpPages(user) { '/refreshuser โ€” Migrate user to latest schema', '/broadcast_retry โ€” Retry failed broadcasts', '/broadcast_failed โ€” List users with failed delivery', - '/on โ€” Enter user mode (bot treats you as regular user)', - '/off โ€” Exit user mode (restore full admin access)', + '/on โ€” Enable admin view', + '/off โ€” Disable admin view (permissions stay admin)', '', '๐Ÿ’ฌ GROUP MANAGEMENT', '/approve_group โ€” Whitelist a group chat', @@ -2431,7 +3173,7 @@ function buildHelpPages(user) { '/admin_backup โ€” Snapshot runtime state', '', 'โ„น๏ธ All commands include smart errors and usage hints.', - 'โ„น๏ธ Use /on to test as a user, /off to restore admin.', + 'โ„น๏ธ Use /on to enable admin view, /off to hide admin UI.', ].join('\n'), buttons: [ [Markup.button.callback('๐Ÿ›  Open Admin Dashboard', 'open_admin_dashboard'), Markup.button.callback('๐Ÿงช Run TestAll', 'admin_cmd_testall')], @@ -2452,8 +3194,8 @@ function buildHelpPages(user) { * @param {number|string} pageOrTab - page number (1-based) or legacy tab string ('user'|'admin') */ async function sendHelpMenu(ctx, user, pageOrTab = 1) { - // Admin in user-mode (/on) sees no admin page โ€” treat as regular user - const adminUser = ADMIN_IDS.includes(Number(user.id)) && !user.adminModeOn; + // Admin help page is visible only when admin view is ON + const adminUser = ADMIN_IDS.includes(Number(user.id)) && user.adminModeOn; // Legacy tab string support let page = typeof pageOrTab === 'string' @@ -2581,8 +3323,18 @@ bot.command('walkthrough', async (ctx) => { }); bot.command('admin', async (ctx) => { - if (!requireAdmin(ctx)) return; + if (!isAdmin(ctx)) { + await sendCommandError(ctx, { + command: 'admin', + reason: 'Admin only', + howToUse: 'Only admins may access the dashboard', + example: '/help', + }); + return; + } const user = getUser(ctx); + persistAdminMode(user, true); + await refreshAdminMenuHeader(ctx, user); const isGroup = ctx.chat && (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'); if (isGroup) { // In groups: send a brief inline button so admin can open dashboard in DM @@ -2592,14 +3344,57 @@ bot.command('admin', async (ctx) => { ); return; } - // In DM: send the persistent admin menu + // In DM: send the persistent admin dashboard await sendPersistentAdminMenu(ctx, user); }); -bot.command('admin_help', async (ctx) => { +bot.command('sshv', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); - await sendHelpMenu(ctx, user, 'admin'); + persistAdminMode(user, true); + await refreshAdminMenuHeader(ctx, user); + const session = getSshvSession(user.id, { createIfMissing: true }); + validateAndFixSshvSession(user, session, { onStartup: false }); + let note = 'Tap Run or reply with a command.'; + if (session.restoredAfterRestart && session.editorMode) { + user.pendingAction = { type: 'await_sshv_editor_content' }; + note = 'Editor session restored. Continue editing?'; + } else { + user.pendingAction = { type: 'await_sshv_command' }; + } + session.restoredAfterRestart = false; + persistSshvSessions(); + await renderSshvConsole(ctx, session, note); +}); + +bot.action('admin_cmd_announce_start', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + clearPendingAction(user); + user.pendingAction = { type: 'await_announcement_text' }; + await ctx.answerCbQuery('Send announcement text'); + await replaceCallbackPanel(ctx, '๐Ÿ“ฃ *Announcement Composer*\n\nSend the announcement text now. You can use normal text or HTML.', { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([[Markup.button.callback('โŒ Cancel', 'open_admin_dashboard')]]), + }); +}); + +bot.action('admin_cmd_tips_dashboard', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await sendTipsDashboard(ctx); +}); + +bot.action('admin_cmd_tiptest', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + const enabled = tipsStore.tips.filter((t) => t.enabled); + if (!enabled.length) { + await ctx.reply('No enabled tips to preview.'); + return; + } + const tip = enabled[Math.floor(Math.random() * enabled.length)]; + await ctx.reply(`Here is a random 4-hour tip preview:\n\n${tip.text}`, { parse_mode: 'Markdown' }); }); // ========================= @@ -2783,6 +3578,9 @@ bot.command('giveaway', async (ctx) => { return; } + const user = getUser(ctx); + if (!user.adminModeOn) persistAdminMode(user, true); + // Admin in a group: offer to start giveaway for this group or list active ones if (isGroup) { const running = Array.from(giveawayStore.running.values()); @@ -2805,7 +3603,6 @@ bot.command('giveaway', async (ctx) => { } // Admin in DM: full wizard - const user = getUser(ctx); await gwizStart(ctx, user, { chatId: ctx.chat.id, chatTitle: 'DM', startedBy: ctx.from.id }); }); @@ -2958,38 +3755,11 @@ bot.command('refreshuser', safeAdminHandler('refreshuser', { usage: '/refreshuse await ctx.reply(`โœ… User ${rawId} refreshed to latest schema.`); })); -bot.command('adminmode', async (ctx) => { - if (!requireAdmin(ctx)) return; - const parts = (ctx.message.text || '').trim().split(/\s+/); - const mode = (parts[1] || '').toLowerCase(); - const user = getUser(ctx); - if (mode === 'on') { - user.adminModeOn = true; - await ctx.reply('๐Ÿ”˜ Admin mode ON โ€” auth bypassed for testing.'); - } else if (mode === 'off') { - user.adminModeOn = false; - await ctx.reply('โšช Admin mode OFF โ€” normal auth restored.'); - } else { - await sendCommandError(ctx, { command: 'adminmode', reason: 'Missing mode argument.', howToUse: '/adminmode on|off', example: '/adminmode on' }); - } -}); - bot.command('health', async (ctx) => { if (!requireAdmin(ctx)) return; - const port = Number(process.env.PORT || 3000); try { - const { data } = await new Promise((resolve, reject) => { - const opts = { hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 5000 }; - const req = require('http').request(opts, (res) => { - let body = ''; - res.on('data', (c) => { body += c; }); - res.on('end', () => resolve({ status: res.statusCode, data: body })); - }); - req.on('error', reject); - req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); }); - req.end(); - }); - await ctx.reply(`๐Ÿฉบ *Health Endpoint*\n\n\`\`\`\n${JSON.stringify(JSON.parse(data), null, 2)}\n\`\`\``, { parse_mode: 'Markdown' }); + const { data, protocol } = await requestHealthPayload(); + await ctx.reply(`๐Ÿฉบ *Health Endpoint (${protocol.toUpperCase()})*\n\n\`\`\`\n${JSON.stringify(JSON.parse(data), null, 2)}\n\`\`\``, { parse_mode: 'Markdown' }); } catch (e) { await ctx.reply(`โŒ Health check failed: ${e.message}`); } @@ -3097,6 +3867,7 @@ bot.command('bonus', async (ctx) => { const rest = parts.slice(3).join(' '); if (sub && isAdmin(ctx)) { + if (!user.adminModeOn) persistAdminMode(user, true); clearPendingAction(user); if (sub === 'pending') { return bonusAdminListPending(ctx); @@ -3182,12 +3953,6 @@ bot.command('leaderboard_weekly', safeStepHandler('leaderboard_weekly', async (c await ctx.reply(`๐Ÿ† *Most Active (7 days)*\n\n${lines.join('\n')}`, { parse_mode: 'Markdown' }); })); -bot.command('admin_dashboard', safeStepHandler('admin_dashboard', async (ctx) => { - if (!requireAdmin(ctx)) return; - const dashboard = buildDashboard(); - await ctx.reply(`Admin Live Dashboard\nUsers: ${dashboard.totalUsers}\nActive: ${dashboard.activeUsers}\nOnboarding complete: ${dashboard.onboardingDone}\nPromo claims: ${dashboard.promoClaims}\nErrors: ${dashboard.errors}\nReferral users: ${dashboard.referralUsers}\nGenerated: ${dashboard.generatedAt}`); -})); - bot.command('boost_referrals', safeStepHandler('boost_referrals', async (ctx) => { if (!requireAdmin(ctx)) return; const parts = String(ctx.message.text || '').split(' '); @@ -3252,39 +4017,21 @@ bot.action('ref_leaderboard', async (ctx) => { await ctx.reply(`๐Ÿ† Referral Leaderboard\n${text}`); }); -bot.command('admin_mode_on', async (ctx) => { - if (!requireAdmin(ctx)) return; - const user = getUser(ctx); - user.adminModeOn = true; - await ctx.reply('๐Ÿ”ง Admin mode ON โ€” admin auth temporarily bypassed. Use /off to restore.'); -}); - -bot.command('admin_mode_off', async (ctx) => { - if (!requireAdmin(ctx)) return; - const user = getUser(ctx); - user.adminModeOn = false; - await ctx.reply('โœ… Admin mode OFF โ€” admin auth restored to normal.'); -}); - -// /on and /off โ€” shorthand admin auth bypass toggles +// /on and /off โ€” admin view visibility toggles (permissions remain identity-based) bot.command('on', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); - user.adminModeOn = true; - await ctx.reply( - '๐Ÿ”˜ *User Mode ON*\n\nYou are now in user mode. All admin commands are blocked and you see the bot exactly as a regular user would.\n\nโ€ข Admin menu is hidden\nโ€ข Admin commands are blocked\nโ€ข Help booklet shows user pages only\n\nUse /off to restore full admin access.', - { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โฌ…๏ธ Main Menu', 'to_main_menu')]]) }, - ); + persistAdminMode(user, true); + await refreshAdminMenuHeader(ctx, user); + await ctx.reply('๐Ÿ”ง Admin Mode Enabled'); }); bot.command('off', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); - user.adminModeOn = false; - await ctx.reply( - 'โœ… Admin mode OFF\n\nAdmin authentication restored. You now have full admin access again.', - Markup.inlineKeyboard([[Markup.button.callback('๐Ÿ”ง Admin Panel', 'menu_admin_tab'), Markup.button.callback('โฌ…๏ธ Main Menu', 'to_main_menu')]]), - ); + persistAdminMode(user, false); + await refreshAdminMenuHeader(ctx, user); + await ctx.reply('๐Ÿ‘ค Admin Mode Disabled'); }); bot.command('bugreport', async (ctx) => { @@ -3412,6 +4159,7 @@ bot.command('join', async (ctx) => { bot.action('to_main_menu', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); + await deleteEphemeralBonusPrompt(ctx, user); await sendPersistentUserMenu(ctx, user); }); @@ -3423,7 +4171,7 @@ bot.action('pmenu_claim_bonus', async (ctx) => { // Show bonus options as temporary message then reappear persistent menu const wb = user.wagerBonus; const canRequest30 = wb.attempts < 3 && !isWagerBonusRequestLocked(wb.status); - await ctx.reply( + await replaceCallbackPanel(ctx, '๐ŸŽ *Bonus Options*\n\n' + 'โ€ข *New User Bonus* โ€” Claim signup promo code (once per account)\n' + 'โ€ข *30 SC Wager Bonus* โ€” Submit your wager proof for 30 SC', @@ -3457,7 +4205,7 @@ bot.action('pmenu_my_profile', async (ctx) => { `Promo claimed: ${user.claimedPromo ? 'โœ…' : 'โŒ'}`, `XP: ${user.profileXP || 0}`, ]; - await ctx.reply(lines.join('\n'), { + await replaceCallbackPanel(ctx, lines.join('\n'), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([ [Markup.button.callback('๐Ÿ”— Link Runewager Username', 'menu_link_runewager')], @@ -3503,7 +4251,7 @@ bot.action('pmenu_help', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); // Show help sub-menu: Command Help Booklet + Bug Report - await ctx.reply( + await replaceCallbackPanel(ctx, 'โ“ *Help & Support*\n\nChoose what you need:', { parse_mode: 'Markdown', @@ -3582,7 +4330,7 @@ bot.action('pamenu_stats_24h', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); const s = buildStatsWindow(24 * 60 * 60 * 1000); - await ctx.reply(buildStatsText('Last 24 hours', 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โ†ฉ Admin Menu', 'pamenu_back_admin')]]) }); + await replaceCallbackPanel(ctx, buildStatsText('Last 24 hours', 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โ†ฉ Admin Menu', 'pamenu_back_admin')]]) }); await sendPersistentAdminMenu(ctx, user); }); @@ -3590,7 +4338,7 @@ bot.action('pamenu_stats_7d', async (ctx) => { await ctx.answerCbQuery(); if (!requireAdmin(ctx)) return; const user = getUser(ctx); - await ctx.reply(buildStatsText('Last 7 days', 7 * 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โ†ฉ Admin Menu', 'pamenu_back_admin')]]) }); + await replaceCallbackPanel(ctx, buildStatsText('Last 7 days', 7 * 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โ†ฉ Admin Menu', 'pamenu_back_admin')]]) }); await sendPersistentAdminMenu(ctx, user); }); @@ -3598,7 +4346,7 @@ bot.action('pamenu_stats_30d', async (ctx) => { await ctx.answerCbQuery(); if (!requireAdmin(ctx)) return; const user = getUser(ctx); - await ctx.reply(buildStatsText('Last 30 days', 30 * 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โ†ฉ Admin Menu', 'pamenu_back_admin')]]) }); + await replaceCallbackPanel(ctx, buildStatsText('Last 30 days', 30 * 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โ†ฉ Admin Menu', 'pamenu_back_admin')]]) }); await sendPersistentAdminMenu(ctx, user); }); @@ -3718,17 +4466,8 @@ bot.action('pamenu_tools_health', async (ctx) => { await ctx.answerCbQuery('Running health check...'); if (!requireAdmin(ctx)) return; try { - const http = require('http'); - const result = await new Promise((resolve, reject) => { - const req = http.get(`http://127.0.0.1:${PORT}/health`, (res) => { - let data = ''; - res.on('data', (chunk) => { data += chunk; }); - res.on('end', () => resolve(data)); - }); - req.on('error', reject); - req.setTimeout(5000, () => { req.destroy(); reject(new Error('timeout')); }); - }); - await ctx.reply(`๐Ÿฉบ Health Check:\n\`\`\`\n${result.slice(0, 800)}\n\`\`\``, { parse_mode: 'Markdown' }); + const { data, protocol } = await requestHealthPayload(); + await ctx.reply(`๐Ÿฉบ Health Check (${protocol.toUpperCase()}):\n\`\`\`\n${data.slice(0, 800)}\n\`\`\``, { parse_mode: 'Markdown' }); } catch (e) { await ctx.reply(`โŒ Health check failed: ${e.message}`); } @@ -4016,6 +4755,7 @@ async function finaliseUsernameLink(ctx, user, normalized, returnTo) { 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; @@ -4280,9 +5020,8 @@ bot.action('help_tab_user', async (ctx) => { }); bot.action('help_tab_admin', async (ctx) => { + if (!requireAdmin(ctx)) return; const user = getUser(ctx); - // Block if not admin, or if admin is in user-mode (/on) - if (!ADMIN_IDS.includes(Number(user.id)) || user.adminModeOn) { await ctx.answerCbQuery('Admin only.'); return; } await ctx.answerCbQuery(); await sendHelpMenu(ctx, user, 6); }); @@ -4294,11 +5033,7 @@ bot.action(/^help_page_(\d+)$/, async (ctx) => { await sendHelpMenu(ctx, user, Number(ctx.match[1])); }); -bot.action('open_help', async (ctx) => { - const user = getUser(ctx); - await ctx.answerCbQuery(); - await sendHelpMenu(ctx, user, 1); -}); + // โ”€โ”€ Menu pagination โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ bot.action('menu_page_1', async (ctx) => { @@ -4381,9 +5116,8 @@ bot.action('menu_bugreport', async (ctx) => { await ctx.reply('๐Ÿ› *Bug Report*\n\nDescribe the bug you encountered. Include steps to reproduce if possible.\n\nOr type /cancel to cancel.', { parse_mode: 'Markdown' }); }); -bot.action('menu_bonus_status', async (ctx) => { - const user = getUser(ctx); - await ctx.answerCbQuery(); +async function replyBonusStatus(ctx, user, answerCallback = false) { + if (answerCallback) await ctx.answerCbQuery(); const wb = user.wagerBonus; const statusEmoji = { none: 'โšช', pending: '๐Ÿ•', approved: 'โœ…', denied: 'โŒ', bonus_sent: '๐ŸŽ‰' }; const text = [ @@ -4399,7 +5133,17 @@ bot.action('menu_bonus_status', async (ctx) => { ...(wb.status === 'none' || wb.status === 'denied' ? [[Markup.button.callback('๐ŸŽฏ Request Bonus', 'w30_request_start')]] : []), [Markup.button.callback('โฌ…๏ธ Menu', 'to_main_menu')], ]; - await ctx.reply(text, { parse_mode: 'Markdown', ...Markup.inlineKeyboard(btns) }); + return ctx.reply(text, { parse_mode: 'Markdown', ...Markup.inlineKeyboard(btns) }); +} + +bot.action('menu_bonus_status', async (ctx) => { + const user = getUser(ctx); + return replyBonusStatus(ctx, user, true); +}); + +bot.action('w30_my_status', async (ctx) => { + const user = getUser(ctx); + return replyBonusStatus(ctx, user, true); }); // โ”€โ”€ Admin dashboard navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -4410,7 +5154,7 @@ bot.action('open_admin_dashboard', async (ctx) => { await sendPersistentAdminMenu(ctx, user); }); -bot.action('admin_dashboard_action', async (ctx) => { +bot.action('admin_dashboard', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); await ctx.answerCbQuery(); @@ -4434,31 +5178,32 @@ bot.action('admin_dash_page_2', async (ctx) => { bot.action('admin_cat_giveaway', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('๐ŸŽ‰ *Giveaway Tools*', { parse_mode: 'Markdown', ...adminGiveawayToolsKeyboard() }); + await replaceCallbackPanel(ctx, '๐ŸŽ‰ *Giveaway Tools*', { parse_mode: 'Markdown', ...adminGiveawayToolsKeyboard() }); }); bot.action('admin_cat_promo', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('๐Ÿ“ฃ *Promo Tools*', { parse_mode: 'Markdown', ...adminPromoToolsKeyboard() }); + await replaceCallbackPanel(ctx, '๐Ÿ“ฃ *Promo Tools*', { parse_mode: 'Markdown', ...adminPromoToolsKeyboard() }); }); bot.action('admin_cat_user', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('๐Ÿ‘ค *User Tools*', { parse_mode: 'Markdown', ...adminUserToolsKeyboard() }); + await replaceCallbackPanel(ctx, '๐Ÿ‘ค *User Tools*', { parse_mode: 'Markdown', ...adminUserToolsKeyboard() }); }); bot.action('admin_cat_system', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('โš™๏ธ *System Tools*', { parse_mode: 'Markdown', ...adminSystemToolsKeyboard() }); + const user = getUser(ctx); + await replaceCallbackPanel(ctx, 'โš™๏ธ *System Tools*', { parse_mode: 'Markdown', ...adminSystemToolsKeyboard(user) }); }); bot.action('admin_cat_support', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('๐Ÿ›Ÿ *Support Tools*', { parse_mode: 'Markdown', ...adminSupportToolsKeyboard() }); + await replaceCallbackPanel(ctx, '๐Ÿ›Ÿ *Support Tools*', { parse_mode: 'Markdown', ...adminSupportToolsKeyboard() }); }); // Inline triggers for admin dashboard quick-action buttons @@ -4514,20 +5259,33 @@ bot.action('admin_backup_action', async (ctx) => { } }); +bot.action('admin_cmd_mode_toggle', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const next = !Boolean(user.adminModeOn); + persistAdminMode(user, next); + await ctx.answerCbQuery(next ? 'Admin mode enabled' : 'Admin mode disabled'); + await refreshAdminMenuHeader(ctx, user); + await sendPersistentAdminMenu(ctx, user); +}); + +// Backward-compatible aliases bot.action('admin_cmd_mode_on', async (ctx) => { if (!requireAdmin(ctx)) return; - await ctx.answerCbQuery('Admin mode enabled'); const user = getUser(ctx); - user.adminModeOn = true; - await ctx.reply('๐Ÿ”˜ Admin mode ON โ€” auth bypassed for testing.'); + persistAdminMode(user, true); + await ctx.answerCbQuery('Admin mode enabled'); + await refreshAdminMenuHeader(ctx, user); + await sendPersistentAdminMenu(ctx, user); }); bot.action('admin_cmd_mode_off', async (ctx) => { if (!requireAdmin(ctx)) return; - await ctx.answerCbQuery('Admin mode disabled'); const user = getUser(ctx); - user.adminModeOn = false; - await ctx.reply('โšช Admin mode OFF โ€” normal auth restored.'); + persistAdminMode(user, false); + await ctx.answerCbQuery('Admin mode disabled'); + await refreshAdminMenuHeader(ctx, user); + await sendPersistentAdminMenu(ctx, user); }); bot.action('admin_cmd_whois_prompt', async (ctx) => { @@ -4554,6 +5312,177 @@ bot.action('admin_cmd_refreshuser_prompt', async (ctx) => { await ctx.reply('๐Ÿ”„ Send the Telegram user ID to refresh:'); }); +bot.action('sshv_run_prompt', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const session = getSshvSession(user.id, { createIfMissing: true }); + if (session.locked) { + await ctx.answerCbQuery('Console locked.'); + await renderSshvConsole(ctx, session, 'Console is locked. Unlock first.'); + return; + } + user.pendingAction = { type: 'await_sshv_command' }; + persistSshvSessions(); + await ctx.answerCbQuery(); + await ctx.reply('Send the shell command to run.'); +}); + +bot.action('sshv_open', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + persistAdminMode(user, true); + const session = getSshvSession(user.id, { createIfMissing: true }); + validateAndFixSshvSession(user, session, { onStartup: false }); + let note = 'Tap Run or reply with a command.'; + if (session.restoredAfterRestart && session.editorMode) { + user.pendingAction = { type: 'await_sshv_editor_content' }; + note = 'Editor session restored. Continue editing?'; + } else { + user.pendingAction = { type: 'await_sshv_command' }; + } + session.restoredAfterRestart = false; + persistSshvSessions(); + await ctx.answerCbQuery(); + await renderSshvConsole(ctx, session, note); +}); + +bot.action('sshv_refresh', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const session = refreshSshvSessionForUser(user); + await ctx.answerCbQuery('SSHV refreshed'); + await renderSshvConsole(ctx, session, 'Session refreshed and state repaired.'); +}); + +bot.action('sshv_ctrl_c', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const session = getSshvSession(user.id); + if (!session) { await ctx.answerCbQuery('No active session.'); return; } + if (session.runningProcess && !session.runningProcess.killed) { + session.runningProcess.kill('SIGINT'); + session.buffer = 'SIGINT sent to running process.'; + await ctx.answerCbQuery('SIGINT sent'); + } else { + await ctx.answerCbQuery('No running process.'); + } + persistSshvSessions(); + await renderSshvConsole(ctx, session); +}); + +bot.action('sshv_ctrl_z', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const session = getSshvSession(user.id); + if (!session) { await ctx.answerCbQuery('No active session.'); return; } + if (session.runningProcess && !session.runningProcess.killed) { + try { session.runningProcess.kill('SIGTSTP'); } catch (_) { session.runningProcess.kill('SIGTERM'); } + session.buffer = 'Stop signal sent to running process.'; + await ctx.answerCbQuery('Stop sent'); + } else { + await ctx.answerCbQuery('No running process.'); + } + persistSshvSessions(); + await renderSshvConsole(ctx, session); +}); + +bot.action('sshv_lock', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const session = getSshvSession(user.id, { createIfMissing: true }); + session.locked = true; + adminLog('sshv_lock', { adminId: user.id }); + persistSshvSessions(); + await ctx.answerCbQuery('Console locked'); + await renderSshvConsole(ctx, session, 'Console locked. Only Unlock is available.'); +}); + +bot.action('sshv_unlock', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const session = getSshvSession(user.id, { createIfMissing: true }); + session.locked = false; + adminLog('sshv_unlock', { adminId: user.id }); + persistSshvSessions(); + await ctx.answerCbQuery('Console unlocked'); + await renderSshvConsole(ctx, session, 'Console unlocked.'); +}); + +bot.action('sshv_exit', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + adminLog('sshv_exit', { adminId: user.id }); + destroySshvSession(user.id); + user.pendingAction = null; + persistSshvSessions(); + await ctx.answerCbQuery('Session closed'); + await sendPersistentAdminMenu(ctx, user); +}); + +bot.action('sshv_confirm_run', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const pending = user.pendingAction; + if (!pending || pending.type !== 'await_sshv_danger_confirm') { + await ctx.answerCbQuery('No command pending.'); + return; + } + const session = getSshvSession(user.id, { createIfMissing: true }); + user.pendingAction = null; + persistSshvSessions(); + await ctx.answerCbQuery('Executing...'); + await executeSshvCommand(ctx, user, session, pending.data.command); +}); + +bot.action('sshv_cancel_run', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const session = getSshvSession(user.id, { createIfMissing: true }); + user.pendingAction = null; + persistSshvSessions(); + await ctx.answerCbQuery('Cancelled'); + session.buffer = 'Cancelled dangerous command.'; + await renderSshvConsole(ctx, session); +}); + +bot.action('sshv_editor_save', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const session = getSshvSession(user.id); + if (!session || !session.editorMode) { await ctx.answerCbQuery('No editor session.'); return; } + const draft = session.editorMode.draft; + if (typeof draft !== 'string') { + await ctx.answerCbQuery('Send edited file content first.'); + return; + } + try { + fs.writeFileSync(session.editorMode.filePath, draft, 'utf8'); + adminLog('sshv_editor_save', { adminId: user.id, filePath: session.editorMode.filePath, size: draft.length }); + session.buffer = `Saved ${session.editorMode.filePath}`; + session.editorMode = null; + user.pendingAction = { type: 'await_sshv_command' }; + persistSshvSessions(); + await ctx.answerCbQuery('Saved'); + await renderSshvConsole(ctx, session); + } catch (e) { + adminLog('sshv_editor_save_failed', { error: e.message, filePath: session.editorMode.filePath, by: user.id }); + await ctx.answerCbQuery('Save failed'); + await ctx.reply(`Save failed: ${e.message}`); + } +}); + +bot.action('sshv_editor_cancel', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const session = getSshvSession(user.id, { createIfMissing: true }); + session.editorMode = null; + user.pendingAction = { type: 'await_sshv_command' }; + persistSshvSessions(); + session.buffer = 'Editor cancelled.'; + await ctx.answerCbQuery('Cancelled'); + await renderSshvConsole(ctx, session); +}); + bot.action('admin_cmd_viewbugs', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); @@ -4603,13 +5532,13 @@ function buildStatsText(label, windowMs) { bot.action('admin_stats_menu', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('๐Ÿ“ˆ *Stats โ€” Select Time Window*', { parse_mode: 'Markdown', ...adminStatsKeyboard() }); + await replaceCallbackPanel(ctx, '๐Ÿ“ˆ *Stats โ€” Select Time Window*', { parse_mode: 'Markdown', ...adminStatsKeyboard() }); }); bot.action('admin_stats_24h', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply(buildStatsText('Last 24 hours', 24 * 60 * 60 * 1000), { + await replaceCallbackPanel(ctx, buildStatsText('Last 24 hours', 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โฌ…๏ธ Back', 'admin_stats_menu')]]), }); @@ -4618,7 +5547,7 @@ bot.action('admin_stats_24h', async (ctx) => { bot.action('admin_stats_7d', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply(buildStatsText('Last 7 days', 7 * 24 * 60 * 60 * 1000), { + await replaceCallbackPanel(ctx, buildStatsText('Last 7 days', 7 * 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โฌ…๏ธ Back', 'admin_stats_menu')]]), }); @@ -4627,7 +5556,7 @@ bot.action('admin_stats_7d', async (ctx) => { bot.action('admin_stats_30d', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply(buildStatsText('Last 30 days', 30 * 24 * 60 * 60 * 1000), { + await replaceCallbackPanel(ctx, buildStatsText('Last 30 days', 30 * 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โฌ…๏ธ Back', 'admin_stats_menu')]]), }); @@ -4636,46 +5565,47 @@ bot.action('admin_stats_30d', async (ctx) => { bot.action('admin_stats_lifetime', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply(buildStatsText('Lifetime', 0), { + await replaceCallbackPanel(ctx, buildStatsText('Lifetime', 0), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('โฌ…๏ธ Back', 'admin_stats_menu')]]), }); }); bot.action('menu_admin_tab', async (ctx) => { + if (!requireAdmin(ctx)) return; const user = getUser(ctx); - // Block if not admin, or if admin is in user-mode (/on) - if (!ADMIN_IDS.includes(Number(user.id)) || user.adminModeOn) { await ctx.answerCbQuery('Admin only.'); return; } await ctx.answerCbQuery(); - const modeStatus = user.adminModeOn ? '๐Ÿ”ง User mode: ON (use /off to restore)' : 'โœ… Admin mode: ACTIVE'; + const modeStatus = user.adminModeOn ? '๐Ÿ”ง Admin view: ON' : '๐Ÿ‘ค Admin view: OFF'; await ctx.reply( `๐Ÿ”ง Admin Panel\n\n${modeStatus}\n\nQuick actions:`, Markup.inlineKeyboard([ [Markup.button.callback('๐Ÿ“„ Promo Controls', 'admin_view'), Markup.button.callback('๐ŸŽฏ Bonus Panel', 'w30_admin_pending')], - [Markup.button.callback('๐Ÿ“Š Dashboard', 'admin_dashboard_action'), Markup.button.callback('๐Ÿ“ฃ Broadcast', 'admin_broadcast')], + [Markup.button.callback('๐Ÿ›  Admin Dashboard', 'admin_dashboard'), Markup.button.callback('๐Ÿ“ฃ Broadcast', 'admin_broadcast')], [Markup.button.callback('๐Ÿ”ง Admin Help', 'help_tab_admin')], [user.adminModeOn - ? Markup.button.callback('โœ… Turn Auth Back ON (/off)', 'admin_auth_restore') - : Markup.button.callback('๐Ÿ”“ Bypass Auth to Test (/on)', 'admin_auth_bypass')], + ? Markup.button.callback('๐Ÿ‘ค Disable Admin Mode (/off)', 'admin_auth_restore') + : Markup.button.callback('๐Ÿ”ง Enable Admin Mode (/on)', 'admin_auth_bypass')], [Markup.button.callback('โฌ…๏ธ Main Menu', 'to_main_menu')], ]), ); }); bot.action('admin_auth_bypass', async (ctx) => { + if (!requireAdmin(ctx)) return; const user = getUser(ctx); - if (!ADMIN_IDS.includes(Number(user.id))) { await ctx.answerCbQuery('Admin only.'); return; } - user.adminModeOn = true; - await ctx.answerCbQuery('Admin auth bypassed.'); - await ctx.reply('๐Ÿ”ง Admin mode ON โ€” use /off to restore auth.'); + persistAdminMode(user, true); + await refreshAdminMenuHeader(ctx, user); + await ctx.answerCbQuery('Admin mode enabled.'); + await ctx.reply('๐Ÿ”ง Admin Mode Enabled'); }); bot.action('admin_auth_restore', async (ctx) => { + if (!requireAdmin(ctx)) return; const user = getUser(ctx); - if (!ADMIN_IDS.includes(Number(user.id))) { await ctx.answerCbQuery('Admin only.'); return; } - user.adminModeOn = false; - await ctx.answerCbQuery('Admin auth restored.'); - await ctx.reply('โœ… Admin mode OFF โ€” full admin auth active.'); + persistAdminMode(user, false); + await refreshAdminMenuHeader(ctx, user); + await ctx.answerCbQuery('Admin mode disabled.'); + await ctx.reply('๐Ÿ‘ค Admin Mode Disabled'); }); bot.action('menu_profile_action', async (ctx) => { @@ -4785,29 +5715,31 @@ bot.action('admin_edit_limit', async (ctx) => { bot.action('admin_pause', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('Pause the current promo?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Pause', 'admin_pause_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); + await replaceCallbackPanel(ctx, 'Pause the current promo?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Pause', 'admin_pause_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); }); bot.action('admin_unpause', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('Unpause the promo?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Unpause', 'admin_unpause_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); + await replaceCallbackPanel(ctx, 'Unpause the promo?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Unpause', 'admin_unpause_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); }); bot.action('admin_remove', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('Are you sure you want to remove the promo?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Remove', 'admin_remove_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); + await replaceCallbackPanel(ctx, 'Are you sure you want to remove the promo?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Remove', 'admin_remove_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); }); bot.action('admin_broadcast', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('Send updated promo to all users?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Broadcast', 'admin_broadcast_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); + await replaceCallbackPanel(ctx, 'Send updated promo to all users?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Broadcast', 'admin_broadcast_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); }); bot.action('admin_cancel', async (ctx) => { + if (!requireAdmin(ctx)) return; await ctx.answerCbQuery('Cancelled'); + await replaceCallbackPanel(ctx, 'โœ… Cancelled. Select another promo action:', { parse_mode: 'Markdown', ...adminPromoToolsKeyboard() }); }); bot.action('admin_pause_yes', async (ctx) => { @@ -5263,6 +6195,19 @@ bot.on('text', async (ctx) => { const text = (ctx.message.text || '').trim(); + if (!user.pendingAction && text.startsWith('/')) { + const cmd = text.slice(1).split(/\s+/)[0].split('@')[0]; + if (!REGISTERED_COMMANDS.has(cmd)) { + await sendCommandError(ctx, { + command: cmd || 'unknown', + reason: 'Unknown command.', + howToUse: 'Use /help to view available commands.', + example: '/help', + }); + return; + } + } + // โ”€โ”€ Feature 25: Support portal โ€” capture step-2 description โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if (user.supportTicket && user.supportTicket.step === 2 && ctx.chat.type === 'private') { const { issueType } = user.supportTicket.data; @@ -5304,6 +6249,38 @@ bot.on('text', async (ctx) => { const action = user.pendingAction; + if (action && action.type === 'await_sshv_command') { + if (!requireAdmin(ctx)) return; + const session = getSshvSession(user.id, { createIfMissing: true }); + await executeSshvCommand(ctx, user, session, text); + return; + } + + if (action && action.type === 'await_sshv_editor_content') { + if (!requireAdmin(ctx)) return; + const session = getSshvSession(user.id, { createIfMissing: true }); + if (!session || !session.editorMode) { + user.pendingAction = { type: 'await_sshv_command' }; + persistSshvSessions(); + await ctx.reply('Editor session expired. Back to console commands.'); + await renderSshvConsole(ctx, session || getSshvSession(user.id, { createIfMissing: true })); + return; + } + session.editorMode.draft = text; + session.lastActivity = Date.now(); + persistSshvSessions(); + await ctx.reply( + `๐Ÿ“ Draft captured for \`${escapeMarkdownFull(session.editorMode.filePath)}\`. Save changes?`, + { + parse_mode: 'MarkdownV2', + ...Markup.inlineKeyboard([ + [Markup.button.callback('๐Ÿ’พ Save', 'sshv_editor_save'), Markup.button.callback('โŒ Cancel', 'sshv_editor_cancel')], + ]), + }, + ); + return; + } + // Admin prompt: whois lookup if (action.type === 'await_admin_whois') { if (!requireAdmin(ctx)) return; @@ -5740,6 +6717,7 @@ bot.on('text', async (ctx) => { tipsStore.nextTipId += 1; user.pendingAction = null; persistRuntimeState(); + saveHelpfulMessages(); await ctx.reply(`Added as Tip #${newTip.id}.`); return; } @@ -5761,6 +6739,7 @@ bot.on('text', async (ctx) => { tip.text = text; user.pendingAction = null; persistRuntimeState(); + saveHelpfulMessages(); await ctx.reply(`Tip #${tipId} updated.`); return; } @@ -5776,6 +6755,7 @@ bot.on('text', async (ctx) => { tipsStore.intervalHours = hours; user.pendingAction = null; persistRuntimeState(); + saveHelpfulMessages(); startTipsScheduler(); // re-arm with new interval await ctx.reply(`โœ… Tip interval updated to every ${hours} hour(s). Scheduler restarted.`); return; @@ -5881,6 +6861,7 @@ bot.action('admin_edit_limit_yes', async (ctx) => { bot.action('gw_create_no', async (ctx) => { const user = getUser(ctx); user.pendingAction = null; + persistSshvSessions(); await ctx.answerCbQuery('Cancelled'); await ctx.reply('Giveaway setup cancelled.'); }); @@ -5966,12 +6947,24 @@ function startTipsScheduler() { if (!tipsStore.systemEnabled) return; const enabled = tipsStore.tips.filter((t) => t.enabled); if (!enabled.length) return; - const tip = enabled[Math.floor(Math.random() * enabled.length)]; + const pool = enabled.length > 1 && tipsStore.lastSentTipId + ? enabled.filter((tip) => tip.id !== tipsStore.lastSentTipId) + : enabled; + const tip = pool[Math.floor(Math.random() * pool.length)]; try { - await bot.telegram.sendMessage(tipsStore.targetGroup, tip.text, { - parse_mode: 'Markdown', + const me = await bot.telegram.getMe(); + const member = await bot.telegram.getChatMember(tipsStore.targetGroup, me.id); + const canSend = member + && (member.status === 'administrator' || member.status === 'creator' || member.can_send_messages); + if (!canSend) { + logEvent('warn', 'Skipping helpful message: missing permission in target group', { target: tipsStore.targetGroup }); + return; + } + await bot.telegram.sendMessage(tipsStore.targetGroup, formatTipForHtml(tip.text), { + parse_mode: 'HTML', disable_notification: true, }); + tipsStore.lastSentTipId = tip.id; logEvent('info', 'Group tip posted', { tipId: tip.id }); } catch (e) { logEvent('warn', 'Failed to post group tip', { tipId: tip.id, error: e.message }); @@ -5986,6 +6979,7 @@ function tipsDashboardKeyboard() { [Markup.button.callback('โŒ Remove Tip', 'tips_cmd_remove'), Markup.button.callback('๐Ÿ” Toggle System', 'tips_cmd_toggle')], [Markup.button.callback('๐Ÿ“‹ View All Tips', 'tips_cmd_list'), Markup.button.callback('๐Ÿงช Test Random Tip', 'tips_cmd_test')], [Markup.button.callback('โš™๏ธ Settings', 'tips_cmd_settings')], + [Markup.button.callback('๐Ÿ“ฅ Import Batch JSON', 'tips_cmd_import_batch')], [Markup.button.callback('โ†ฉ Admin Menu', 'pamenu_back_admin')], ]); } @@ -6009,7 +7003,7 @@ async function sendTipsDashboard(ctx) { const enabled = tipsStore.tips.filter((t) => t.enabled).length; const status = tipsStore.systemEnabled ? '๐ŸŸข Enabled' : '๐Ÿ”ด Disabled'; const text = `๐Ÿ“ *Tips Manager*\n\nStatus: ${status}\nTotal Tips: ${total} (${enabled} active)\nInterval: every ${tipsStore.intervalHours}h\nTarget: ${tipsStore.targetGroup}`; - await ctx.reply(text, { parse_mode: 'Markdown', ...tipsDashboardKeyboard() }); + await replaceCallbackPanel(ctx, text, { parse_mode: 'Markdown', ...tipsDashboardKeyboard() }); } // โ”€โ”€ /tips /tiplist /tipadd /tipremove /tipedit /tiptoggle /tiptest /tipsettings โ”€โ”€ @@ -6026,19 +7020,42 @@ bot.command('tp', handleTipsCommand); bot.command('tiplist', async (ctx) => { if (!requireAdmin(ctx)) return; const lines = ['๐Ÿ“‹ *Current Tips:*\n']; - for (const tip of tipsStore.tips) { + for (const [idx, tip] of tipsStore.tips.entries()) { const preview = tip.text.replace(/\*/g, '').slice(0, 80); - lines.push(`${tip.id}. ${tip.enabled ? '' : '๐Ÿ”‡ '}${preview}${tip.text.length > 80 ? 'โ€ฆ' : ''}`); + lines.push(`${idx + 1}. #${tip.id} ${tip.enabled ? 'โœ…' : '๐Ÿ”‡'} ${preview}${tip.text.length > 80 ? 'โ€ฆ' : ''}`); } await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown' }); }); bot.command('tipadd', async (ctx) => { if (!requireAdmin(ctx)) return; + const input = ctx.message.text.replace(/^\/tipadd\b\s*/i, '').trim(); + if (input.startsWith('[')) { + try { + const arr = JSON.parse(input); + if (!Array.isArray(arr)) throw new Error('JSON must be an array'); + let added = 0; + for (const entry of arr) { + const normalized = normalizeHelpfulMessageEntry(entry, tipsStore.nextTipId); + if (!normalized) continue; + normalized.id = tipsStore.nextTipId; + tipsStore.nextTipId += 1; + tipsStore.tips.push(normalized); + added += 1; + } + persistRuntimeState(); + saveHelpfulMessages(); + await ctx.reply(`โœ… Imported ${added} helpful messages (append mode).`); + return; + } catch (e) { + await ctx.reply(`Invalid JSON array for /tipadd import: ${e.message}`); + return; + } + } const user = getUser(ctx); clearPendingAction(user); user.pendingAction = { type: 'await_tip_add_text' }; - await ctx.reply('Send the new tip text. HTML and Markdown are both allowed.'); + await ctx.reply('Send the new tip text, or pass a JSON array directly to /tipadd for append import.'); }); bot.command('tipremove', async (ctx) => { @@ -6049,6 +7066,27 @@ bot.command('tipremove', async (ctx) => { bot.command('tipedit', async (ctx) => { if (!requireAdmin(ctx)) return; + const input = ctx.message.text.replace(/^\/tipedit\b\s*/i, '').trim(); + if (input.startsWith('[')) { + try { + const arr = JSON.parse(input); + if (!Array.isArray(arr)) throw new Error('JSON must be an array'); + const normalized = arr + .map((entry, idx) => normalizeHelpfulMessageEntry(entry, idx + 1)) + .filter(Boolean) + .map((entry, idx) => ({ ...entry, id: idx + 1 })); + if (!normalized.length) throw new Error('No valid messages found in payload'); + tipsStore.tips = normalized; + tipsStore.nextTipId = normalized.length + 1; + persistRuntimeState(); + saveHelpfulMessages(); + await ctx.reply(`โœ… Overwrote helpful messages with ${normalized.length} imported entries.`); + return; + } catch (e) { + await ctx.reply(`Invalid JSON array for /tipedit overwrite: ${e.message}`); + return; + } + } if (!tipsStore.tips.length) { await ctx.reply('No tips to edit.'); return; } await ctx.reply('Edit which tip?', tipSelectKeyboard('tip_edit_select')); }); @@ -6057,6 +7095,7 @@ bot.command('tiptoggle', async (ctx) => { if (!requireAdmin(ctx)) return; tipsStore.systemEnabled = !tipsStore.systemEnabled; persistRuntimeState(); + saveHelpfulMessages(); const status = tipsStore.systemEnabled ? '๐ŸŸข Tips System Enabled' : '๐Ÿ”ด Tips System Disabled'; await ctx.reply(status); }); @@ -6064,9 +7103,23 @@ bot.command('tiptoggle', async (ctx) => { bot.command('tiptest', async (ctx) => { if (!requireAdmin(ctx)) return; const enabled = tipsStore.tips.filter((t) => t.enabled); - if (!enabled.length) { await ctx.reply('No enabled tips to preview.'); return; } - const tip = enabled[Math.floor(Math.random() * enabled.length)]; - await ctx.reply(`Here is a random tip preview:\n\n${tip.text}`, { parse_mode: 'Markdown' }); + if (!enabled.length) { await ctx.reply('No enabled tips to test.'); return; } + const pool = enabled.length > 1 && tipsStore.lastSentTipId != null + ? enabled.filter((t) => t.id !== tipsStore.lastSentTipId) + : enabled; + const tip = pool[Math.floor(Math.random() * pool.length)]; + try { + await bot.telegram.sendMessage(tipsStore.targetGroup, formatTipForHtml(tip.text), { + parse_mode: 'HTML', + disable_notification: true, + }); + tipsStore.lastSentTipId = tip.id; + persistRuntimeState(); + saveHelpfulMessages(); + await ctx.reply(`โœ… Test tip posted to ${tipsStore.targetGroup} (silent).`); + } catch (e) { + await ctx.reply(`โŒ Failed to post test tip to ${tipsStore.targetGroup}: ${e.message}`); + } }); bot.command('tipsettings', async (ctx) => { @@ -6108,6 +7161,7 @@ bot.action('tips_cmd_toggle', async (ctx) => { await ctx.answerCbQuery(); tipsStore.systemEnabled = !tipsStore.systemEnabled; persistRuntimeState(); + saveHelpfulMessages(); const status = tipsStore.systemEnabled ? '๐ŸŸข Tips System Enabled' : '๐Ÿ”ด Tips System Disabled'; await ctx.reply(status); await sendTipsDashboard(ctx); @@ -6117,9 +7171,9 @@ bot.action('tips_cmd_list', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); const lines = ['๐Ÿ“‹ *Current Tips:*\n']; - for (const tip of tipsStore.tips) { + for (const [idx, tip] of tipsStore.tips.entries()) { const preview = tip.text.replace(/\*/g, '').slice(0, 80); - lines.push(`${tip.id}. ${tip.enabled ? '' : '๐Ÿ”‡ '}${preview}${tip.text.length > 80 ? 'โ€ฆ' : ''}`); + lines.push(`${idx + 1}. #${tip.id} ${tip.enabled ? 'โœ…' : '๐Ÿ”‡'} ${preview}${tip.text.length > 80 ? 'โ€ฆ' : ''}`); } await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown' }); }); @@ -6128,9 +7182,33 @@ bot.action('tips_cmd_test', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); const enabled = tipsStore.tips.filter((t) => t.enabled); - if (!enabled.length) { await ctx.reply('No enabled tips to preview.'); return; } - const tip = enabled[Math.floor(Math.random() * enabled.length)]; - await ctx.reply(`Here is a random tip preview:\n\n${tip.text}`, { parse_mode: 'Markdown' }); + if (!enabled.length) { await ctx.reply('No enabled tips to test.'); return; } + const pool = enabled.length > 1 && tipsStore.lastSentTipId != null + ? enabled.filter((t) => t.id !== tipsStore.lastSentTipId) + : enabled; + const tip = pool[Math.floor(Math.random() * pool.length)]; + try { + await bot.telegram.sendMessage(tipsStore.targetGroup, formatTipForHtml(tip.text), { + parse_mode: 'HTML', + disable_notification: true, + }); + tipsStore.lastSentTipId = tip.id; + persistRuntimeState(); + saveHelpfulMessages(); + await ctx.reply(`โœ… Test tip posted to ${tipsStore.targetGroup} (silent).`); + } catch (e) { + await ctx.reply(`โŒ Failed to post test tip to ${tipsStore.targetGroup}: ${e.message}`); + } + await sendTipsDashboard(ctx); +}); + +bot.action('tips_cmd_import_batch', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + await ctx.answerCbQuery(); + clearPendingAction(user); + user.pendingAction = { type: 'await_tip_add_text' }; + await ctx.reply('Paste a JSON array to append tips (supports plain text or HTML):\n\n/tipadd [{"text":"My Tip","enabled":true}]'); }); bot.action('tips_cmd_settings', async (ctx) => { @@ -6160,6 +7238,7 @@ bot.action(/^tip_remove_(\d+)$/, async (ctx) => { if (idx === -1) { await ctx.reply('Tip not found.'); return; } tipsStore.tips.splice(idx, 1); persistRuntimeState(); + saveHelpfulMessages(); await ctx.reply(`Tip #${tipId} removed.`); }); @@ -6185,6 +7264,7 @@ bot.action(/^tip_toggle_(\d+)$/, async (ctx) => { if (!tip) { await ctx.reply('Tip not found.'); return; } tip.enabled = !tip.enabled; persistRuntimeState(); + saveHelpfulMessages(); await ctx.reply(`Tip #${tipId} is now ${tip.enabled ? 'โœ… enabled' : '๐Ÿ”‡ disabled'}.`); }); @@ -7161,6 +8241,46 @@ bot.command('testall', async (ctx) => { else throw new Error('not a function'); } catch (e) { fail('Admin Dashboard', 'sendCommandError', e.message); } + // โ”€โ”€ 6b. Menu & Callback Audit โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + try { + const source = fs.readFileSync(__filename, 'utf8'); + const callbackPattern = /bot\.action\((?:'([^']+)'|"([^"]+)")/g; + const registered = new Set(); + let m; + while ((m = callbackPattern.exec(source)) !== null) { + registered.add((m[1] || m[2] || '').trim()); + } + + const sampleUser = createDefaultUser({ id: -101, username: 'menuaudit', first_name: 'Menu' }); + sampleUser.adminModeOn = true; + const menuKeyboards = [ + ['adminMainMenuKeyboard', adminMainMenuKeyboard(sampleUser)], + ['adminDashboardKeyboard1', adminDashboardKeyboard(1)], + ['adminDashboardKeyboard2', adminDashboardKeyboard(2)], + ['adminStatsKeyboard', adminStatsKeyboard()], + ['adminGiveawayToolsKeyboard', adminGiveawayToolsKeyboard()], + ['adminPromoToolsKeyboard', adminPromoToolsKeyboard()], + ['adminUserToolsKeyboard', adminUserToolsKeyboard()], + ['adminSystemToolsKeyboard', adminSystemToolsKeyboard(sampleUser)], + ['adminSupportToolsKeyboard', adminSupportToolsKeyboard()], + ['tipsDashboardKeyboard', tipsDashboardKeyboard()], + ['userMainMenuKeyboard', userMainMenuKeyboard(true)], + ]; + + for (const [name, kb] of menuKeyboards) { + const ids = extractCallbackDataFromKeyboard(kb); + const duplicates = ids.filter((id, idx) => ids.indexOf(id) !== idx); + if (duplicates.length === 0) pass('Menu & Callback Audit', `${name}.no_duplicate_buttons`); + else fail('Menu & Callback Audit', `${name}.no_duplicate_buttons`, `Duplicates: ${Array.from(new Set(duplicates)).join(', ')}`); + + const missing = ids.filter((id) => !registered.has(id) && !id.startsWith('tip_') && !id.startsWith('page_') && !id.startsWith('walk_') && !id.startsWith('gw_') && !id.startsWith('tgw_dur_')); + if (missing.length === 0) pass('Menu & Callback Audit', `${name}.callbacks_registered`); + else fail('Menu & Callback Audit', `${name}.callbacks_registered`, `Missing: ${Array.from(new Set(missing)).join(', ')}`); + } + } catch (e) { + fail('Menu & Callback Audit', 'menu_callback_audit', e.message); + } + // โ”€โ”€ 7. Settings System โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ try { const freshUser = createDefaultUser({ id: -2, username: 'y', first_name: 'Y' }); @@ -7457,7 +8577,27 @@ async function runTestGiveawayFinale(gw, adminId) { function startHealthServer() { const port = Number(process.env.PORT || 3000); - const server = http.createServer((req, res) => { + const tlsKeyPath = process.env.HTTPS_KEY_PATH; + const tlsCertPath = process.env.HTTPS_CERT_PATH; + if (!tlsKeyPath || !tlsCertPath) { + logEvent('warn', 'Health server starting in HTTP-only mode: HTTPS cert/key not configured'); + } + + let key = null; + let cert = null; + if (tlsKeyPath && tlsCertPath) { + try { + key = fs.readFileSync(resolveTlsCertPathIfAllowed(tlsKeyPath)); + cert = fs.readFileSync(resolveTlsCertPathIfAllowed(tlsCertPath)); + } catch (e) { + _startupWarnings.push(`โš ๏ธ Startup Warning +TLS cert/key read failed: ${e.message} +Falling back to HTTP-only health server.`); + logEvent('warn', 'TLS cert/key read failed; using HTTP-only health server', { error: e.message }); + } + } + + const handler = (req, res) => { if (req.url === '/health') { // Disk free (MB) โ€” best-effort via /proc/statvfs equivalent let diskFreeMB = null; @@ -7528,7 +8668,9 @@ function startHealthServer() { } res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'not_found' })); - }); + }; + + const server = key && cert ? https.createServer({ key, cert }, handler) : require('http').createServer(handler); // Bind to loopback only โ€” health/metrics are internal; firewall blocks external access server.listen(port, '127.0.0.1'); @@ -7592,8 +8734,7 @@ bot.command('gw_resume', safeAdminHandler('gw_resume', { usage: '/gw_resume })); bot.action(/^gw_pause_(\d+)$/, async (ctx) => { - const _pauseUser = getUser(ctx); - if (!isAdmin(ctx) || _pauseUser.adminModeOn) { await ctx.answerCbQuery('Admins only.'); return; } + if (!requireAdmin(ctx)) return; const gwId = Number(ctx.match[1]); const gw = giveawayStore.running.get(gwId); await ctx.answerCbQuery(); @@ -7607,8 +8748,7 @@ bot.action(/^gw_pause_(\d+)$/, async (ctx) => { }); bot.action(/^gw_resume_(\d+)$/, async (ctx) => { - const _resumeUser = getUser(ctx); - if (!isAdmin(ctx) || _resumeUser.adminModeOn) { await ctx.answerCbQuery('Admins only.'); return; } + if (!requireAdmin(ctx)) return; const gwId = Number(ctx.match[1]); const gw = giveawayStore.running.get(gwId); await ctx.answerCbQuery(); @@ -7757,8 +8897,36 @@ bot.command('unapprove_group', safeAdminHandler('unapprove_group', { usage: '/un 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' }); + const me = await bot.telegram.getMe(); + const report = [`Approved Groups Report (${approvedGroupsStore.size})`]; + for (const chatId of approvedGroupsStore) { + try { + const chat = await bot.telegram.getChat(chatId); + const member = await bot.telegram.getChatMember(chatId, me.id); + const isAdmin = member.status === 'administrator' || member.status === 'creator'; + const hasPower = (flag) => isAdmin || Boolean(flag); + const missing = []; + if (!hasPower(member.can_send_messages)) missing.push('send messages'); + if (!(hasPower(member.can_send_audios) && hasPower(member.can_send_photos) && hasPower(member.can_send_videos) && hasPower(member.can_send_documents))) missing.push('send media'); + if (!hasPower(member.can_send_other_messages)) missing.push('send other message types'); + if (!hasPower(member.can_add_web_page_previews)) missing.push('add web previews'); + if (!hasPower(member.can_invite_users)) missing.push('invite users'); + if (!hasPower(member.can_pin_messages)) missing.push('pin messages'); + if (!hasPower(member.can_manage_chat)) missing.push('manage chat'); + + report.push([ + '', + `โ€ข ${chat.title || chatId} (${chatId})`, + ` Bot admin: ${isAdmin ? 'YES' : 'NO'}`, + ` Missing permissions: ${missing.length ? missing.join(', ') : 'none'}`, + ' Recommended BotFather: disable privacy mode if group message visibility is required.', + ` Privacy mode: ${process.env.BOT_PRIVACY_MODE || 'unknown (check @BotFather)'}`, + ].join('\n')); + } catch (e) { + report.push(`\nโ€ข ${chatId}\n Error: ${e.message}`); + } + } + await ctx.reply(report.join('\n').slice(0, 3900)); })); // โ”€โ”€ Feature 9: Admin Activity Log โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -8151,6 +9319,26 @@ async function startBot() { } } +// Cleanup expired /sshv sessions every minute to keep memory usage bounded. +const sshvGcTimer = setInterval(() => { + const now = Date.now(); + for (const [adminId, session] of sshvSessions.entries()) { + if (now - (session.lastActivity || session.createdAt || 0) > SSHV_SESSION_TTL_MS) { + destroySshvSession(adminId); + } + } +}, 60 * 1000); +if (typeof sshvGcTimer.unref === 'function') sshvGcTimer.unref(); + +// Fallback for unmatched callback_data: always acknowledge and provide recovery path. +bot.action(/.*/, async (ctx) => { + await ctx.answerCbQuery('Action not available anymore.').catch(() => {}); + await ctx.reply( + 'โš ๏ธ That button is no longer active. Please open /menu and try again.', + Markup.inlineKeyboard([[Markup.button.callback('๐Ÿ  Main Menu', 'to_main_menu')]]), + ).catch(() => {}); +}); + // ========================= // Runtime environment guard // diff --git a/prod-run.sh b/prod-run.sh index b4063fb..c45e6ef 100755 --- a/prod-run.sh +++ b/prod-run.sh @@ -26,17 +26,162 @@ APP_NAME="runewager" LOG_DIR="$PROJECT_DIR/logs" MAIN_LOG="$LOG_DIR/${APP_NAME}.log" ERROR_LOG="$LOG_DIR/${APP_NAME}-error.log" +ADMIN_EVENTS_LOG="$PROJECT_DIR/data/admin-events.log" +SSHV_SESSIONS_FILE="$PROJECT_DIR/data/sshv-sessions.json" SERVICE_FILE="/etc/systemd/system/${APP_NAME}.service" LOGROTATE_FILE="/etc/logrotate.d/${APP_NAME}" +HEALTH_TLS_INSECURE="${HEALTH_TLS_INSECURE:-0}" say() { printf '[%s] %s\n' "$APP_NAME" "$*"; } warn() { printf '[%s][warn] %s\n' "$APP_NAME" "$*" >&2; } err() { printf '[%s][error] %s\n' "$APP_NAME" "$*" >&2; } + +SERVICE_USER="$APP_NAME" +SERVICE_GROUP="$APP_NAME" + +ensure_service_user() { + if id -u "$APP_NAME" >/dev/null 2>&1; then + return 0 + fi + + if [[ "$(id -u)" -ne 0 ]]; then + SERVICE_USER="$(id -un)" + SERVICE_GROUP="$(id -gn)" + warn "System user '$APP_NAME' not found and current user is not root โ€” using ${SERVICE_USER}:${SERVICE_GROUP}" + return 1 + fi + + if ! getent group "$APP_NAME" >/dev/null 2>&1; then + groupadd --system "$APP_NAME" + fi + + if ! id -u "$APP_NAME" >/dev/null 2>&1; then + useradd --system --gid "$APP_NAME" --home-dir "$PROJECT_DIR" --shell /usr/sbin/nologin "$APP_NAME" + fi + + say "Created system user/group '$APP_NAME' for service runtime" +} + +read_env_value() { + local key="$1" + [[ -f "$PROJECT_DIR/.env" ]] || return 1 + grep -E "^${key}=" "$PROJECT_DIR/.env" | head -1 | cut -d= -f2- | sed "s/^[\'\"]//;s/[\'\"]$//" | tr -d "\r" +} + +resolve_health_url() { + local port tls_key tls_cert + port="$(read_env_value PORT)" + [[ -z "$port" ]] && port="3000" + tls_key="$(read_env_value HTTPS_KEY_PATH)" + tls_cert="$(read_env_value HTTPS_CERT_PATH)" + + if [[ -n "$tls_key" && -n "$tls_cert" && -f "$tls_key" && -f "$tls_cert" ]]; then + printf 'https://127.0.0.1:%s/health' "$port" + else + printf 'http://127.0.0.1:%s/health' "$port" + fi +} + +check_health_endpoint() { + local url="$1" + local code + if [[ "$url" == https://* ]]; then + if [[ "$HEALTH_TLS_INSECURE" == "1" ]]; then + warn "HEALTH_TLS_INSECURE=1 set; skipping TLS cert verification for health probe" + code="$(curl -k -s -o /dev/null -w "%{http_code}" --max-time 5 "$url" 2>/dev/null || echo 000)" + else + code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$url" 2>/dev/null || echo 000)" + fi + else + code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$url" 2>/dev/null || echo 000)" + fi + [[ "$code" == "200" ]] +} + +wait_for_health() { + local url="$1" + local attempts="${2:-15}" + local sleep_sec="${3:-2}" + local i + for ((i=1; i<=attempts; i++)); do + if check_health_endpoint "$url"; then + return 0 + fi + sleep "$sleep_sec" + done + return 1 +} + +is_port_listening() { + local port="$1" + local port_hex + if command -v ss >/dev/null 2>&1; then + ss -ltn "sport = :${port}" 2>/dev/null | awk 'NR>1 {found=1} END {exit(found ? 0 : 1)}' && return 0 + fi + + if command -v netstat >/dev/null 2>&1; then + netstat -ltn 2>/dev/null | awk -v p=":${port}" 'index($4,p){found=1} END{exit(found?0:1)}' && return 0 + fi + + if [[ -r /proc/net/tcp ]]; then + port_hex="$(printf '%04X' "$port" | tr '[:lower:]' '[:upper:]')" + awk -v p=":${port_hex}" 'NR>1 && index($2,p)==(length($2)-4) {found=1} END{exit(found?0:1)}' /proc/net/tcp && return 0 + fi + + return 1 +} + +port_listener_pids() { + local port="$1" + + if command -v ss >/dev/null 2>&1; then + ss -ltnp "sport = :${port}" 2>/dev/null | sed -n 's/.*pid=\([0-9]\+\).*/\1/p' | sort -u + return + fi + + if command -v lsof >/dev/null 2>&1; then + lsof -t -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null | sort -u + return + fi + + if command -v fuser >/dev/null 2>&1; then + fuser -n tcp "${port}" 2>/dev/null | tr ' ' '\n' | sed '/^$/d' | sort -u + fi +} + +free_port_if_conflicted() { + local port="$1" + local bot_pid="$2" + local killed=0 + local pid + + while IFS= read -r pid; do + [[ -z "$pid" ]] && continue + if [[ -n "$bot_pid" && "$pid" == "$bot_pid" ]]; then + continue + fi + warn "Killing process $pid occupying port ${port}" + kill -TERM "$pid" 2>/dev/null || true + sleep 1 + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || true + fi + killed=1 + done < <(port_listener_pids "$port") + + return $killed +} + say "Starting production runner" say "Project directory: $PROJECT_DIR" say "Running as: $(id -un) (uid=$(id -u) gid=$(id -g))" +ensure_service_user || true +if [[ "$SERVICE_USER" != "$APP_NAME" ]]; then + warn "Using fallback service identity ${SERVICE_USER}:${SERVICE_GROUP}; ownership/service behavior may differ from root-managed installs" +fi + # --------------------------------------------------------- # 1) Fetch + hard reset to origin/main [FIRST โ€” before anything] # Using fetch+reset instead of pull avoids hangs on dirty trees / conflicts. @@ -57,7 +202,13 @@ fi mkdir -p "$LOG_DIR" mkdir -p "$PROJECT_DIR/data" mkdir -p "$PROJECT_DIR/data/backups" -touch "$MAIN_LOG" "$ERROR_LOG" # create if missing; safe on existing files +touch "$MAIN_LOG" "$ERROR_LOG" "$ADMIN_EVENTS_LOG" "$SSHV_SESSIONS_FILE" # create if missing; safe on existing files + +if id -u "$SERVICE_USER" >/dev/null 2>&1; then + chown -R "$SERVICE_USER:$SERVICE_GROUP" "$LOG_DIR" "$PROJECT_DIR/data" +fi +chmod 0750 "$PROJECT_DIR/data" "$PROJECT_DIR/data/backups" "$LOG_DIR" +chmod 0640 "$MAIN_LOG" "$ERROR_LOG" "$ADMIN_EVENTS_LOG" "$SSHV_SESSIONS_FILE" say "Ensured runtime directories and log files exist" @@ -101,6 +252,14 @@ if [[ -f "$PROJECT_DIR/.env" ]]; then fi fi +# Lock down .env permissions (contains secrets) +if [[ -f "$PROJECT_DIR/.env" ]]; then + chmod 0600 "$PROJECT_DIR/.env" || true + if id -u "$SERVICE_USER" >/dev/null 2>&1; then + chown "$SERVICE_USER:$SERVICE_GROUP" "$PROJECT_DIR/.env" || true + fi +fi + # --------------------------------------------------------- # 6) Always install / update production dependencies # Running npm ci on every deploy ensures deps stay in sync @@ -133,17 +292,9 @@ ensure_systemd_service() { local node_bin node_bin="$(command -v node || echo /usr/bin/node)" - # Use the 'runewager' system user if it exists; otherwise fall back - # to the current user so the service can still be created safely. local svc_user svc_group - if id -u "$APP_NAME" >/dev/null 2>&1; then - svc_user="$APP_NAME" - svc_group="$APP_NAME" - else - svc_user="$(id -un)" - svc_group="$(id -gn)" - warn "System user '$APP_NAME' not found โ€” using $svc_user:$svc_group for service" - fi + svc_user="$SERVICE_USER" + svc_group="$SERVICE_GROUP" local conf conf=$(cat </dev/null || id -u)" + gid_num="$(id -g "$SERVICE_GROUP" 2>/dev/null || id -g)" local conf conf=$(cat </dev/null 2>&1 && [[ -f "$SERVICE_FILE" ]]; then if systemctl restart "${APP_NAME}.service" 2>&1; then sleep 3 @@ -371,12 +537,56 @@ else fi SYSTEMD_ACTIVE="$(systemctl is-active "${APP_NAME}.service" 2>/dev/null || echo inactive)" +HEALTH_URL="$(resolve_health_url)" +HEALTH_PORT="${HEALTH_URL#*://127.0.0.1:}" +HEALTH_PORT="${HEALTH_PORT%%/*}" + +if wait_for_health "$HEALTH_URL" 20 2; then + HEALTH_STATUS="healthy" +else + HEALTH_STATUS="unhealthy" + + # Auto-recover from port conflicts, then retry health once. + if is_port_listening "$HEALTH_PORT"; then + free_port_if_conflicted "$HEALTH_PORT" "$PID" || true + if command -v systemctl >/dev/null 2>&1 && [[ -f "$SERVICE_FILE" ]]; then + systemctl restart "${APP_NAME}.service" 2>/dev/null || true + else + [[ -n "$PID" ]] && { kill "$PID" 2>/dev/null || true; sleep 2; } + nohup node "$PROJECT_DIR/index.js" >> "$MAIN_LOG" 2>> "$ERROR_LOG" < /dev/null & + disown || true + fi + sleep 3 + PID="$(get_bot_pid)" + if wait_for_health "$HEALTH_URL" 10 2; then + HEALTH_STATUS="healthy" + fi + fi +fi + +if is_port_listening "$HEALTH_PORT"; then + PORT_STATUS="listening" +else + PORT_STATUS="not-listening" +fi + say "โœ” Systemd service: ${SYSTEMD_ACTIVE}" +say "โœ” Health URL: ${HEALTH_URL}" +say "โœ” Health endpoint: ${HEALTH_STATUS}" +say "โœ” Port ${HEALTH_PORT}: ${PORT_STATUS}" say "โœ” Logrotate installed: $(command -v logrotate >/dev/null 2>&1 && echo yes || echo no)" say "โœ” Logs directory: $LOG_DIR" say "โœ” Main log: $MAIN_LOG" say "โœ” Error log: $ERROR_LOG" +if [[ "$HEALTH_STATUS" != "healthy" ]]; then + warn "Health endpoint check failed after retries; dumping last 50 log lines" + echo "---- Last 50 lines of $MAIN_LOG ----" + tail -n 50 "$MAIN_LOG" 2>/dev/null || true + echo "---- Last 50 lines of $ERROR_LOG ----" + tail -n 50 "$ERROR_LOG" 2>/dev/null || true +fi + echo "--------------------------------------------------" say "Production runner complete" echo "--------------------------------------------------" diff --git a/runewager.service b/runewager.service index 52b9612..ab0cc19 100644 --- a/runewager.service +++ b/runewager.service @@ -25,8 +25,8 @@ Wants=network-online.target Type=simple WorkingDirectory=/var/www/html/Runewager ExecStart=/usr/bin/node /var/www/html/Runewager/index.js -User=root -Group=root +User=runewager +Group=runewager EnvironmentFile=/var/www/html/Runewager/.env Environment=NODE_ENV=production @@ -43,6 +43,12 @@ TimeoutStopSec=30 # Logging: append mode โ€” survives log rotation without restart StandardOutput=append:/var/www/html/Runewager/logs/runewager.log StandardError=append:/var/www/html/Runewager/logs/runewager-error.log +ReadWritePaths=/var/www/html/Runewager/logs /var/www/html/Runewager/data +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +NoNewPrivileges=true +UMask=0077 # File descriptor limit (for concurrent connections) LimitNOFILE=65536 diff --git a/scripts/self-diagnose.sh b/scripts/self-diagnose.sh index 07f5208..170a380 100755 --- a/scripts/self-diagnose.sh +++ b/scripts/self-diagnose.sh @@ -1,28 +1,77 @@ #!/usr/bin/env bash # ============================================================================= -# self-diagnose.sh โ€” Full system diagnostic. Can be called from CI or manually. -# -# Runs all available checks and prints a comprehensive status report. -# Does NOT abort on failure โ€” reports everything and exits with the summary. +# self-diagnose.sh โ€” Full system diagnostic snapshot. +# Captures state first, then prints one internally consistent report. +# Safe/read-only: does not restart services or modify system files. # ============================================================================= set -euo pipefail -# Resolve project root: prefer the script's own parent directory so this works -# regardless of where it is called from. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BASE_DIR="${BASE_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)}" CURRENT_DIR="$BASE_DIR" PORT="${PORT:-3000}" -# Load .env (best-effort) if [[ -f "${CURRENT_DIR}/.env" ]]; then - set -o allexport; source "${CURRENT_DIR}/.env" 2>/dev/null || true; set +o allexport + set -o allexport + # shellcheck disable=SC1090 + source "${CURRENT_DIR}/.env" 2>/dev/null || true + set +o allexport fi -PASS=0; FAIL=0; WARN=0 -ok() { echo " โœ… $*"; PASS=$((PASS+1)); } -fail() { echo " โŒ $*"; FAIL=$((FAIL+1)); } -warn() { echo " โš ๏ธ $*"; WARN=$((WARN+1)); } +PASS=0 +WARN=0 +FAIL=0 + +SYSTEM_LINES=() +FILES_LINES=() +ENV_LINES=() +SERVICE_LINES=() +PROCESS_LINES=() +NETWORK_LINES=() +RELEASE_LINES=() +LOG_LINES=() +SNAPSHOT_LINES=() + +RENDERED_LINE="" + +add_result() { + local level="$1" + local msg="$2" + case "$level" in + ok) + printf -v RENDERED_LINE ' โœ… %s' "$msg" + PASS=$((PASS + 1)) + ;; + warn) + printf -v RENDERED_LINE ' โš ๏ธ %s' "$msg" + WARN=$((WARN + 1)) + ;; + fail) + printf -v RENDERED_LINE ' โŒ %s' "$msg" + FAIL=$((FAIL + 1)) + ;; + *) + printf -v RENDERED_LINE ' โ€ข %s' "$msg" + ;; + esac +} + +append_line() { + local section="$1" + local level="$2" + local msg="$3" + add_result "$level" "$msg" + case "$section" in + system) SYSTEM_LINES+=("$RENDERED_LINE") ;; + files) FILES_LINES+=("$RENDERED_LINE") ;; + env) ENV_LINES+=("$RENDERED_LINE") ;; + service) SERVICE_LINES+=("$RENDERED_LINE") ;; + process) PROCESS_LINES+=("$RENDERED_LINE") ;; + network) NETWORK_LINES+=("$RENDERED_LINE") ;; + releases) RELEASE_LINES+=("$RENDERED_LINE") ;; + snapshot) SNAPSHOT_LINES+=("$RENDERED_LINE") ;; + esac +} banner() { echo "" @@ -31,105 +80,224 @@ banner() { echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" } -echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" -echo " RUNEWAGER BOT โ€” SELF-DIAGNOSE REPORT" -echo " Generated: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" -echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" +get_pid_start_ticks() { + local pid="$1" + [[ -n "$pid" ]] || return 1 + [[ -r "/proc/${pid}/stat" ]] || return 1 + awk '{print $22}' "/proc/${pid}/stat" 2>/dev/null +} -# โ”€โ”€ System โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -banner "1. System" -NODE_VER=$(node --version 2>/dev/null || echo "not found") -NODE_MAJOR=$(echo "$NODE_VER" | sed 's/v\([0-9]*\).*/\1/' 2>/dev/null || echo 0) -if [[ "$NODE_MAJOR" -ge 20 ]]; then ok "Node $NODE_VER"; else fail "Node $NODE_VER (need >= 20)"; fi -NPM_VER=$(npm --version 2>/dev/null || echo "not found") -ok "npm $NPM_VER" -OS_INFO=$(uname -srm 2>/dev/null || echo "unknown") -ok "OS: $OS_INFO" -DISK_AVAIL=$(df -BM "${CURRENT_DIR}" 2>/dev/null | awk 'NR==2 {gsub(/M/,"",$4); print $4}' || echo 0) -if [[ "$DISK_AVAIL" -gt 500 ]]; then ok "Disk: ${DISK_AVAIL}MB free" -elif [[ "$DISK_AVAIL" -gt 100 ]]; then warn "Disk: ${DISK_AVAIL}MB free (low)" -else fail "Disk: ${DISK_AVAIL}MB free (critical)"; fi -MEM_FREE=$(awk '/MemAvailable/ {printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0) -if [[ "$MEM_FREE" -gt 100 ]]; then ok "Memory: ${MEM_FREE}MB free" -elif [[ "$MEM_FREE" -gt 50 ]]; then warn "Memory: ${MEM_FREE}MB free (low)" -else fail "Memory: ${MEM_FREE}MB free (critical)"; fi -CPU_LOAD=$(awk '{print $1}' /proc/loadavg 2>/dev/null || echo "?") -ok "CPU load (1m): $CPU_LOAD" - -# โ”€โ”€ Files โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -banner "2. Files" +get_bot_pid() { + pgrep -f "node.*${CURRENT_DIR}/index\\.js" | head -n 1 || true +} + +# -------- Snapshot capture begins (no output beyond this point until finished) -------- +SNAPSHOT_TS="$(date -u '+%Y-%m-%d %H:%M:%S UTC')" + +PID_BEFORE="$(get_bot_pid)" +PID_BEFORE_TICKS="$(get_pid_start_ticks "$PID_BEFORE" || true)" + +# 1) System +NODE_VER="$(node --version 2>/dev/null || echo 'not found')" +NODE_MAJOR="$(echo "$NODE_VER" | sed 's/v\([0-9]*\).*/\1/' 2>/dev/null || echo 0)" +if [[ "$NODE_MAJOR" =~ ^[0-9]+$ ]] && [[ "$NODE_MAJOR" -ge 20 ]]; then + append_line system ok "Node $NODE_VER" +else + append_line system fail "Node $NODE_VER (need >= 20)" +fi + +NPM_VER="$(npm --version 2>/dev/null || echo 'not found')" +append_line system ok "npm $NPM_VER" + +OS_INFO="$(uname -srm 2>/dev/null || echo 'unknown')" +append_line system ok "OS: $OS_INFO" + +DISK_AVAIL="$(df -BM "${CURRENT_DIR}" 2>/dev/null | awk 'NR==2 {gsub(/M/,"",$4); print $4}' || echo 0)" +if [[ "$DISK_AVAIL" =~ ^[0-9]+$ ]] && [[ "$DISK_AVAIL" -gt 500 ]]; then + append_line system ok "Disk: ${DISK_AVAIL}MB free" +elif [[ "$DISK_AVAIL" =~ ^[0-9]+$ ]] && [[ "$DISK_AVAIL" -gt 100 ]]; then + append_line system warn "Disk: ${DISK_AVAIL}MB free (low)" +else + append_line system fail "Disk: ${DISK_AVAIL}MB free (critical)" +fi + +MEM_FREE="$(awk '/MemAvailable/ {printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0)" +if [[ "$MEM_FREE" =~ ^[0-9]+$ ]] && [[ "$MEM_FREE" -gt 100 ]]; then + append_line system ok "Memory: ${MEM_FREE}MB free" +elif [[ "$MEM_FREE" =~ ^[0-9]+$ ]] && [[ "$MEM_FREE" -gt 50 ]]; then + append_line system warn "Memory: ${MEM_FREE}MB free (low)" +else + append_line system fail "Memory: ${MEM_FREE}MB free (critical)" +fi + +CPU_LOAD="$(awk '{print $1}' /proc/loadavg 2>/dev/null || echo '?')" +append_line system ok "CPU load (1m): $CPU_LOAD" + +# 2) Files for f in index.js package.json .env; do - if [[ -f "${CURRENT_DIR}/${f}" ]]; then ok "${f} present" - else fail "${f} MISSING"; fi + if [[ -f "${CURRENT_DIR}/${f}" ]]; then + append_line files ok "${f} present" + else + append_line files fail "${f} MISSING" + fi done -if [[ -d "${CURRENT_DIR}/node_modules/telegraf" ]]; then ok "telegraf installed" -else fail "telegraf not installed"; fi -if [[ -d "${CURRENT_DIR}/data" ]]; then ok "data/ directory exists" -else warn "data/ missing (will be created on start)"; fi -if [[ -d "${CURRENT_DIR}/logs" ]]; then ok "logs/ directory exists" -else warn "logs/ missing (will be created on start)"; fi - -# โ”€โ”€ Environment โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -banner "3. Environment" + +if [[ -d "${CURRENT_DIR}/node_modules/telegraf" ]]; then + append_line files ok "telegraf installed" +else + append_line files fail "telegraf not installed" +fi + +if [[ -d "${CURRENT_DIR}/data" ]]; then + append_line files ok "data/ directory exists" +else + append_line files warn "data/ missing (will be created on start)" +fi + +if [[ -d "${CURRENT_DIR}/logs" ]]; then + append_line files ok "logs/ directory exists" +else + append_line files warn "logs/ missing (will be created on start)" +fi + +# 3) Environment for var in BOT_TOKEN ADMIN_IDS PORT MINI_APP_PLAY_URL AFFILIATE_SOURCE; do val="${!var:-}" if [[ -n "$val" ]]; then [[ "$var" == "BOT_TOKEN" ]] && val="[hidden]" - ok "$var = $val" - else fail "$var is not set"; fi + append_line env ok "$var = $val" + else + append_line env fail "$var is not set" + fi done -# โ”€โ”€ Service โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -banner "4. Systemd Service" -SVC_STATUS=$(systemctl is-active runewager.service 2>/dev/null || echo "inactive") -if [[ "$SVC_STATUS" == "active" ]]; then ok "runewager.service active" -else fail "runewager.service: $SVC_STATUS"; fi -SVC_ENABLED=$(systemctl is-enabled runewager.service 2>/dev/null || echo "disabled") -if [[ "$SVC_ENABLED" == "enabled" ]]; then ok "Service enabled (auto-start)" -else warn "Service not enabled ($SVC_ENABLED)"; fi - -# โ”€โ”€ Process โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -banner "5. Bot Process" -BOT_PID=$(pgrep -f "node.*${CURRENT_DIR}/index\.js" | head -n 1 || true) +# 4) Service +SVC_STATUS="$(systemctl is-active runewager.service 2>/dev/null || echo inactive)" +if [[ "$SVC_STATUS" == "active" ]]; then + append_line service ok "runewager.service active" +else + append_line service fail "runewager.service: $SVC_STATUS" +fi + +SVC_ENABLED="$(systemctl is-enabled runewager.service 2>/dev/null || echo disabled)" +if [[ "$SVC_ENABLED" == "enabled" ]]; then + append_line service ok "Service enabled (auto-start)" +else + append_line service warn "Service not enabled ($SVC_ENABLED)" +fi + +# 5) Process +BOT_PID="$PID_BEFORE" if [[ -n "$BOT_PID" ]]; then - ok "Bot running (PID: $BOT_PID)" - BOT_MEM=$(awk '/VmRSS/ {printf "%d", $2/1024}' /proc/${BOT_PID}/status 2>/dev/null || echo "?") - ok "Bot memory: ${BOT_MEM}MB RSS" -else warn "Bot process not found (service may be restarting)"; fi + append_line process ok "Bot running (PID: $BOT_PID)" + BOT_MEM="$(awk '/VmRSS/ {printf "%d", $2/1024}' "/proc/${BOT_PID}/status" 2>/dev/null || echo '?')" + append_line process ok "Bot memory: ${BOT_MEM}MB RSS" +else + append_line process warn "Bot process not found (service may be restarting)" +fi + +# 6) Network +HEALTH_CODE="$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://127.0.0.1:${PORT}/health" 2>/dev/null || echo 000)" +if [[ "$HEALTH_CODE" == "200" ]]; then + append_line network ok "Health endpoint OK (HTTP 200)" +else + append_line network fail "Health endpoint HTTP $HEALTH_CODE" +fi + +STATUS_CODE="$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://127.0.0.1:${PORT}/status" 2>/dev/null || echo 000)" +if [[ "$STATUS_CODE" == "200" ]]; then + append_line network ok "Status endpoint OK (HTTP 200)" +else + append_line network warn "Status endpoint HTTP $STATUS_CODE" +fi + +TG_CODE="$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "https://api.telegram.org" 2>/dev/null || echo 000)" +if [[ "$TG_CODE" =~ ^[0-9]+$ ]] && [[ "$TG_CODE" -ge 200 ]] && [[ "$TG_CODE" -lt 500 ]]; then + append_line network ok "Telegram API reachable (HTTP $TG_CODE)" +else + append_line network fail "Telegram API HTTP $TG_CODE" +fi + +if ss -tln 2>/dev/null | grep -q ":${PORT} "; then + append_line network ok "Port $PORT listening" +elif command -v netstat >/dev/null 2>&1 && netstat -tln 2>/dev/null | grep -q ":${PORT} "; then + append_line network ok "Port $PORT listening" +else + append_line network fail "Port $PORT not listening" +fi + +# 7) Releases +RELEASE_LIST="$(find "${BASE_DIR}/releases" -maxdepth 1 -type d -name 'release_*' 2>/dev/null || true)" +RELEASE_COUNT="$(printf '%s\n' "$RELEASE_LIST" | sed '/^$/d' | wc -l | tr -d ' ')" +append_line releases ok "$RELEASE_COUNT release(s) available in ${BASE_DIR}/releases/" + +LATEST_RELEASE_PATH="$(printf '%s\n' "$RELEASE_LIST" | sed '/^$/d' | sort | tail -n 1)" +if [[ -n "$LATEST_RELEASE_PATH" ]]; then + append_line releases ok "Latest release: $(basename "$LATEST_RELEASE_PATH")" +else + append_line releases ok "Latest release: none" +fi -# โ”€โ”€ Network โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -banner "6. Network" -HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://127.0.0.1:${PORT}/health" 2>/dev/null || echo "000") -if [[ "$HTTP_CODE" == "200" ]]; then ok "Health endpoint OK (HTTP 200)" -else fail "Health endpoint HTTP $HTTP_CODE"; fi -STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://127.0.0.1:${PORT}/status" 2>/dev/null || echo "000") -if [[ "$STATUS_CODE" == "200" ]]; then ok "Status endpoint OK (HTTP 200)" -else warn "Status endpoint HTTP $STATUS_CODE"; fi -TG_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "https://api.telegram.org" 2>/dev/null || echo "000") -if [[ "$TG_CODE" -ge 200 ]] && [[ "$TG_CODE" -lt 500 ]]; then ok "Telegram API reachable (HTTP $TG_CODE)" -else fail "Telegram API HTTP $TG_CODE"; fi -if ss -tlnp 2>/dev/null | grep -q ":${PORT} " || netstat -tlnp 2>/dev/null | grep -q ":${PORT} "; then - ok "Port $PORT listening" -else fail "Port $PORT not listening"; fi - -# โ”€โ”€ Releases โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -banner "7. Releases" -RELEASE_COUNT=$(ls -1d "${BASE_DIR}/releases"/release_* 2>/dev/null | wc -l || echo 0) -ok "$RELEASE_COUNT release(s) available in ${BASE_DIR}/releases/" -LATEST_RELEASE=$(ls -1dt "${BASE_DIR}/releases"/release_* 2>/dev/null | head -n 1 || echo "none") -ok "Latest release: $(basename "$LATEST_RELEASE")" if [[ -f "${CURRENT_DIR}/.last_rollback" ]]; then - warn "Last rollback: $(cat "${CURRENT_DIR}/.last_rollback")" + append_line releases warn "Last rollback: $(cat "${CURRENT_DIR}/.last_rollback")" fi -# โ”€โ”€ Logs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -banner "8. Recent Logs (last 5 lines)" +# 8) Logs snapshot LOG_FILE="${CURRENT_DIR}/logs/runewager.log" if [[ -f "$LOG_FILE" ]]; then - tail -n 5 "$LOG_FILE" | while IFS= read -r line; do echo " $line"; done -else warn "Log file not found: $LOG_FILE"; fi + while IFS= read -r line; do + LOG_LINES+=(" $line") + done < <(tail -n 5 "$LOG_FILE" 2>/dev/null || true) +else + add_result warn "Log file not found: $LOG_FILE" + LOG_LINES+=("$RENDERED_LINE") +fi + +# Restart/change detection at end of capture window +PID_AFTER="$(get_bot_pid)" +PID_AFTER_TICKS="$(get_pid_start_ticks "$PID_AFTER" || true)" + +if [[ "$PID_BEFORE" != "$PID_AFTER" ]]; then + append_line snapshot warn "Bot PID changed during snapshot (${PID_BEFORE:-none} -> ${PID_AFTER:-none})" +elif [[ -n "$PID_BEFORE" ]] && [[ -n "$PID_AFTER" ]] && [[ "$PID_BEFORE_TICKS" != "$PID_AFTER_TICKS" ]]; then + append_line snapshot warn "Bot process restarted during snapshot (same PID reused)" +else + append_line snapshot ok "No bot restart detected during snapshot" +fi + +# -------- Printing begins (no checks beyond this point) -------- +echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" +echo " RUNEWAGER BOT โ€” SELF-DIAGNOSE REPORT" +echo " Snapshot: $SNAPSHOT_TS" +echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + +banner "0. Snapshot Integrity" +printf '%s\n' "${SNAPSHOT_LINES[@]}" + +banner "1. System" +printf '%s\n' "${SYSTEM_LINES[@]}" + +banner "2. Files" +printf '%s\n' "${FILES_LINES[@]}" + +banner "3. Environment" +printf '%s\n' "${ENV_LINES[@]}" + +banner "4. Systemd Service" +printf '%s\n' "${SERVICE_LINES[@]}" + +banner "5. Bot Process" +printf '%s\n' "${PROCESS_LINES[@]}" + +banner "6. Network" +printf '%s\n' "${NETWORK_LINES[@]}" + +banner "7. Releases" +printf '%s\n' "${RELEASE_LINES[@]}" + +banner "8. Recent Logs (last 5 lines)" +printf '%s\n' "${LOG_LINES[@]}" -# โ”€โ”€ Summary โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ echo "" echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" echo " SUMMARY" @@ -139,7 +307,7 @@ echo " โš ๏ธ Warnings: $WARN" echo " โŒ Failed: $FAIL" echo "" if [[ $FAIL -gt 0 ]]; then - echo " โŒ System has issues. Check the failures above." + echo " โŒ System has issues. Check failures above." exit 1 elif [[ $WARN -gt 0 ]]; then echo " โš ๏ธ System OK with warnings."