From 72dcf9c042e72f169671c48005f3244eb7709c5a Mon Sep 17 00:00:00 2001 From: GambleCodez Affiliates Date: Tue, 24 Feb 2026 20:37:20 -0600 Subject: [PATCH] Fix PR-72 follow-ups: admin guard purity, health fallback, and SSHV hardening --- .env.example | 6 + .gitignore | 55 +- Dockerfile | 27 - LICENSE | 21 + README.md | 9 +- SECURITY.md | 21 + deploy.sh | 2 + index.js | 1323 +++++++++++++++++++++++++++++++++++++++------ prod-run.sh | 6 +- runewager.service | 9 +- 10 files changed, 1232 insertions(+), 247 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..aa33a6a 100755 --- a/deploy.sh +++ b/deploy.sh @@ -189,6 +189,8 @@ 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/logs" + touch "$PROJECT_DIR/data/sshv-sessions.json" "$PROJECT_DIR/data/admin-events.log" 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..d59f544 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 }); @@ -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() { @@ -692,25 +822,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 +867,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 +1501,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 +1539,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')], ]; @@ -1136,6 +1736,7 @@ function adminUserToolsKeyboard() { function adminSystemToolsKeyboard() { 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('๐Ÿ’พ Backup State', 'admin_backup_action')], @@ -1230,7 +1831,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 +1857,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 +1886,8 @@ 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; + // 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 +1898,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 }); @@ -1307,19 +1917,17 @@ async function sendPersistentUserMenu(ctx, user) { /** Keyboard for the persistent ADMIN MAIN MENU */ function adminMainMenuKeyboard() { 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('๐Ÿ”ง Mode Toggle: ON (/on)', 'admin_cmd_mode_on'), Markup.button.callback('๐Ÿ‘ค Mode Toggle: OFF (/off)', 'admin_cmd_mode_off')], [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 +1935,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,7 +1960,16 @@ async function sendPersistentAdminMenu(ctx, user) { user.adminMenuChatId = null; } - const text = adminMainMenuText(); + // 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(); const sent = await ctx.telegram.sendMessage(chatId, text, { parse_mode: 'Markdown', ...keyboard }); user.adminMenuMsgId = sent.message_id; @@ -1581,21 +2200,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 +2366,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 +2378,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 +2487,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 +2553,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 +2564,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 +2768,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 +2994,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 +3026,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 +3034,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 +3072,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 +3093,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 +3222,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 +3243,27 @@ 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); }); // ========================= @@ -2783,6 +3447,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 +3472,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 +3624,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 +3736,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 +3822,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 +3886,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) => { @@ -3718,17 +4334,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 +4623,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 +4888,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 +4901,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 +4984,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 +5001,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 +5022,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(); @@ -4518,16 +5130,18 @@ 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 refreshAdminMenuHeader(ctx, user); + await ctx.reply('๐Ÿ”ง Admin Mode Enabled'); }); 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 refreshAdminMenuHeader(ctx, user); + await ctx.reply('๐Ÿ‘ค Admin Mode Disabled'); }); bot.action('admin_cmd_whois_prompt', async (ctx) => { @@ -4554,6 +5168,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(); @@ -4643,39 +5428,40 @@ bot.action('admin_stats_lifetime', async (ctx) => { }); 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) => { @@ -5263,6 +6049,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 +6103,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 +6571,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 +6593,7 @@ bot.on('text', async (ctx) => { tip.text = text; user.pendingAction = null; persistRuntimeState(); + saveHelpfulMessages(); await ctx.reply(`Tip #${tipId} updated.`); return; } @@ -5776,6 +6609,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 +6715,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 +6801,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 { + 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, tip.text, { parse_mode: 'Markdown', 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 }); @@ -6026,19 +6873,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 +6919,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 +6948,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); }); @@ -6108,6 +7000,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 +7010,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' }); }); @@ -6160,6 +7053,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 +7079,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'}.`); }); @@ -7457,7 +8352,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 +8443,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 +8509,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 +8523,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 +8672,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 +9094,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..6821bee 100755 --- a/prod-run.sh +++ b/prod-run.sh @@ -26,6 +26,8 @@ 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}" @@ -57,7 +59,7 @@ 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 say "Ensured runtime directories and log files exist" @@ -205,7 +207,7 @@ ensure_logrotate() { local conf conf=$(cat <