diff --git a/CLAUDE.md b/CLAUDE.md index f0826da..c0db952 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,7 @@ You are the Runewager Bot Ops Commander. You manage the full lifecycle of the Ru - Use `clearOldMenus(ctx)` (or approved wrapper) for menu-card transitions to enforce single-active-menu behavior. - Keep link behavior deterministic: browser mode => external web URL only; telegram mode => startapp deep link only; Discord URLs must remain external browser links. - `/testall` updates must preserve structured category checks and final summary: `TestAll complete โ€” X passed, Y warnings, Z failures.` +- QA tester contract: maintain `/qa_on`, `/qa_off`, `/qa_mode`, `/qa_status` behavior and keep `qa/context/bot_capabilities.json`, `qa/context/repo_info.json`, and `qa/state/provider_status.json` synchronized with runtime changes. - Referral onboarding rules must be enforced in code/tests: one-time code entry during onboarding only, no self-referral, no post-onboarding referral application, and dual 7-day boost assignment on valid referral. ## Workflow Patterns diff --git a/RUNEWAGER_FUNCTIONALITY_MAP.md b/RUNEWAGER_FUNCTIONALITY_MAP.md index 44542bf..812f417 100644 --- a/RUNEWAGER_FUNCTIONALITY_MAP.md +++ b/RUNEWAGER_FUNCTIONALITY_MAP.md @@ -48,6 +48,8 @@ Navigation is driven by inline menus plus command aliases. Persistent user/admin - Many menu-style responses use `replaceCallbackPanel(...)` to avoid stale stacked cards. - Single active menu rule: `clearOldMenus(ctx)` + menu send helpers keep only one active transient menu card per user/chat. - `to_main_menu` clears transient menu cards via `clearOldMenus(...)` before rendering persistent menu headers. +- SSHV command prompt reliability: `sshv_run_prompt` seeds `await_sshv_command` and private-DM admin text falls back to active SSHV session execution even if pending state was lost. +- QA control commands exist for tester integration: `/qa_on`, `/qa_off`, `/qa_mode user|admin`, `/qa_status`. ## 4. User Menu Tree @@ -356,3 +358,5 @@ Mandatory rules for any AI agent touching this repo: - 2026-02-26: Added `load_tooltips.sh` to seed `/var/www/html/Runewager/data/tooltips.json` (15 UTF-8 HTML tooltips) and wired runtime tooltip loading to prefer that system JSON on restart. - 2026-02-26: Added deterministic 30 SC user submenu (`How It Works`, `Check My Eligibility`, `Request My Bonus`, `Check Bonus Status`) and Admin submenu (`View Pending Requests`, `Approve Bonus`, `Deny Bonus`, `View User History`, `Reset Attempts`) with manual-review copy and admin action logging to `/var/www/html/Runewager/logs/bonus_admin.log`. +- 2026-02-27: Added QA tester scaffolding (`qa/context/bot_capabilities.json`, `qa/context/repo_info.json`, `qa/state/provider_status.json`, `qa/README_QA.md`), with runtime refresh via `/qa_*` commands and 10-minute provider cooldown reset. +- 2026-02-27: Hardened SSHV Run prompt flow so admin text in private DM executes against active SSHV sessions if pending state desynchronizes. diff --git a/index.js b/index.js index 072cb5a..e51a34c 100644 --- a/index.js +++ b/index.js @@ -325,6 +325,13 @@ const helpfulMessagesFile = path.join(dataDir, 'helpful_messages.json'); const systemTooltipsFile = '/var/www/html/Runewager/data/tooltips.json'; const bonusAdminLogFile = '/var/www/html/Runewager/logs/bonus_admin.log'; const sshvSessionsFile = path.join(dataDir, 'sshv-sessions.json'); +const qaDir = path.join(__dirname, 'qa'); +const qaContextDir = path.join(qaDir, 'context'); +const qaStateDir = path.join(qaDir, 'state'); +const qaLogsDir = path.join(qaDir, 'logs'); +const qaCapabilitiesFile = path.join(qaContextDir, 'bot_capabilities.json'); +const qaRepoInfoFile = path.join(qaContextDir, 'repo_info.json'); +const qaProviderStateFile = path.join(qaStateDir, 'provider_status.json'); /** @@ -449,6 +456,18 @@ 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} +const qaRuntime = { + enabled: false, + mode: 'user', + providerStatus: { + current: 'deepseek', + providers: { deepseek: { status: 'ready', cooldownUntil: 0 }, gemini: { status: 'ready', cooldownUntil: 0 }, chatgpt: { status: 'ready', cooldownUntil: 0 } }, + retryAfterMs: 60000, + resetIntervalMs: 10 * 60 * 1000, + updatedAt: Date.now(), + }, +}; + // Absolute path to the project root (same directory as this file) const PROJECT_DIR = path.resolve(__dirname); @@ -542,6 +561,10 @@ function evaluatePendingActionTimeout(user, now = Date.now()) { } const REGISTERED_COMMANDS = new Set([ + 'qa_mode', + 'qa_on', + 'qa_off', + 'qa_status', 'A', 'a', 'admin', @@ -4514,6 +4537,90 @@ function saveJson(filePath, data) { writeFileAtomic(filePath, JSON.stringify(data, null, 2)); } +function ensureQaDirs() { + fs.mkdirSync(qaContextDir, { recursive: true }); + fs.mkdirSync(qaStateDir, { recursive: true }); + fs.mkdirSync(qaLogsDir, { recursive: true }); +} + +function getQaLogDirForToday() { + const day = new Date().toISOString().slice(0, 10); + const dir = path.join(qaLogsDir, day); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeQaLog(fileName, payload) { + try { + const line = `${JSON.stringify({ ts: new Date().toISOString(), ...payload })}\n`; + fs.appendFileSync(path.join(getQaLogDirForToday(), fileName), line, 'utf8'); + } catch (_) { /* ignore QA log write errors */ } +} + +function collectPendingActionTypesFromSource(source) { + const out = new Set(); + for (const m of source.matchAll(/pendingAction\s*=\s*\{\s*type:\s*['"`]([^'"`]+)['"`]/g)) out.add(m[1]); + return Array.from(out).sort(); +} + +function collectCallbacksFromSource(source) { + const out = new Set(); + for (const m of source.matchAll(/bot\.action\(\s*['"`]([^'"`]+)['"`]/g)) out.add(m[1]); + return Array.from(out).sort(); +} + +function generateQaCapabilities() { + const source = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8'); + const mapSummary = fs.readFileSync(path.join(__dirname, 'RUNEWAGER_FUNCTIONALITY_MAP.md'), 'utf8').slice(0, 2000); + return { + generatedAt: new Date().toISOString(), + commands: Array.from(REGISTERED_COMMANDS).sort(), + callbacks: collectCallbacksFromSource(source), + pendingActions: collectPendingActionTypesFromSource(source), + onboardingSteps: ['Age Gate', 'Account Setup', 'Referral Prompt', 'Username Link', 'Promo Guidance', 'Community Join'], + modes: ['user', 'admin'], + qaControls: ['/qa_on', '/qa_off', '/qa_mode user|admin', '/qa_status'], + rules: { + telegramDefault: true, + discordTesting: 'postponed', + providerFallbackOrder: ['deepseek', 'gemini', 'chatgpt'], + }, + mapExcerpt: mapSummary, + }; +} + +function persistQaProviderState() { + ensureQaDirs(); + fs.writeFileSync(qaProviderStateFile, JSON.stringify(qaRuntime.providerStatus, null, 2)); +} + +function refreshQaProviderCooldowns() { + const now = Date.now(); + for (const provider of Object.keys(qaRuntime.providerStatus.providers)) { + if (qaRuntime.providerStatus.providers[provider].cooldownUntil <= now) { + qaRuntime.providerStatus.providers[provider].status = 'ready'; + qaRuntime.providerStatus.providers[provider].cooldownUntil = 0; + } + } + qaRuntime.providerStatus.updatedAt = now; + persistQaProviderState(); +} + +function ensureQaArtifacts() { + ensureQaDirs(); + fs.writeFileSync(qaRepoInfoFile, JSON.stringify({ + repoPath: '/var/www/html/Runewager', + branch: process.env.GIT_BRANCH || 'main', + systemdService: 'runewager.service', + entryFile: 'index.js', + telegramDefault: true, + }, null, 2)); + fs.writeFileSync(qaCapabilitiesFile, JSON.stringify(generateQaCapabilities(), null, 2)); + persistQaProviderState(); +} + +setInterval(refreshQaProviderCooldowns, qaRuntime.providerStatus.resetIntervalMs).unref(); + /** * addBadge executes its scoped Runewager logic and participates in menu/command or utility flow composition. @@ -5968,6 +6075,55 @@ bot.command('sshv', async (ctx) => { await renderSshvConsole(ctx, session, note); }); +bot.command('qa_on', async (ctx) => { + if (!requireAdmin(ctx)) return; + qaRuntime.enabled = true; + ensureQaArtifacts(); + writeQaLog('action_log.json', { event: 'qa_on', adminId: ctx.from.id }); + await ctx.reply('๐Ÿงช QA mode is now ON. Debug metadata logging enabled.'); +}); + +bot.command('qa_off', async (ctx) => { + if (!requireAdmin(ctx)) return; + qaRuntime.enabled = false; + writeQaLog('action_log.json', { event: 'qa_off', adminId: ctx.from.id }); + await ctx.reply('๐Ÿงช QA mode is now OFF. Bot replies are back to normal mode.'); +}); + +bot.command('qa_mode', async (ctx) => { + if (!requireAdmin(ctx)) return; + const parts = String(ctx.message?.text || '').trim().split(/\s+/); + const mode = String(parts[1] || '').toLowerCase(); + if (!['user', 'admin'].includes(mode)) { + await ctx.reply('Usage: /qa_mode user OR /qa_mode admin'); + return; + } + qaRuntime.mode = mode; + ensureQaArtifacts(); + writeQaLog('action_log.json', { event: 'qa_mode', adminId: ctx.from.id, mode }); + await ctx.reply(`๐Ÿงช QA mode set to: ${mode}`); +}); + +bot.command('qa_status', async (ctx) => { + if (!requireAdmin(ctx)) return; + ensureQaArtifacts(); + const providerState = fs.existsSync(qaProviderStateFile) + ? JSON.parse(fs.readFileSync(qaProviderStateFile, 'utf8')) + : qaRuntime.providerStatus; + await ctx.reply( + [ + '๐Ÿงช QA Status', + `Enabled: ${qaRuntime.enabled ? 'ON' : 'OFF'}`, + `Mode: ${qaRuntime.mode}`, + `Provider: ${providerState.current}`, + `Capabilities: ${qaCapabilitiesFile}`, + `Logs: ${qaLogsDir}`, + 'Fallback order: deepseek โ†’ gemini โ†’ chatgpt', + 'Telegram default testing: true', + ].join('\n'), + ); +}); + // ========================= // Admin Announce Command โ€” /A /a /announce // State machine: @@ -8527,10 +8683,10 @@ bot.action('sshv_run_prompt', async (ctx) => { await renderSshvConsole(ctx, session, 'Console is locked. Unlock first.'); return; } - user.pendingAction = { type: 'await_sshv_command' }; + user.pendingAction = { type: 'await_sshv_command', createdAt: Date.now() }; persistSshvSessions(); await ctx.answerCbQuery(); - await ctx.reply('Send the shell command to run.'); + await ctx.reply('Send the shell command to run (example: ls -la).'); }); bot.action('sshv_open', async (ctx) => { @@ -9672,6 +9828,14 @@ bot.on('text', async (ctx) => { if (!user.pendingAction && await autoRegisterForwardedChatIfPresent(ctx, user)) return; + if (!user.pendingAction && isAdmin(ctx) && ctx.chat?.type === 'private' && !text.startsWith('/')) { + const session = getSshvSession(user.id); + if (session && session.active) { + await executeSshvCommand(ctx, user, session, text); + return; + } + } + // Smart username detection โ€” fires when there is NO pending action, the message looks // like a Runewager username (3-30 chars, alphanumeric/underscore/hyphen/dot, no spaces), // and the user has not yet linked an account. Always requires confirmation โ€” never auto-accepts. @@ -13908,6 +14072,7 @@ async function runWeeklyBoostReminder() { async function startBot() { try { loadPersistentData(); + ensureQaArtifacts(); persistRuntimeState(); logEvent('info', 'Starting Runewager Bot', { admins: ADMIN_IDS.length, node: process.version, host: os.hostname() }); await configureBotSurface(); diff --git a/qa/README_QA.md b/qa/README_QA.md new file mode 100644 index 0000000..8899395 --- /dev/null +++ b/qa/README_QA.md @@ -0,0 +1,21 @@ +# Runewager QA Integration + +This bot supports a Telegram-first QA tester workflow. + +## Commands +- `/qa_on` enable QA metadata/logging mode (admin only) +- `/qa_off` disable QA metadata/logging mode (admin only) +- `/qa_mode user|admin` set test perspective (admin only) +- `/qa_status` show QA runtime status and artifact paths (admin only) + +## Artifacts +- `qa/context/bot_capabilities.json` generated capability map +- `qa/context/repo_info.json` deployment metadata +- `qa/state/provider_status.json` provider fallback/cooldown state +- `qa/logs/YYYY-MM-DD/` per-day logs + +## Provider fallback +Order: deepseek โ†’ gemini โ†’ chatgpt, retry after 60 seconds, cooldown reset every 10 minutes. + +## Scope +Telegram is the default test target (`telegramDefault=true`). Discord test automation is postponed. diff --git a/qa/context/bot_capabilities.json b/qa/context/bot_capabilities.json new file mode 100644 index 0000000..02fbcaa --- /dev/null +++ b/qa/context/bot_capabilities.json @@ -0,0 +1,14 @@ +{ + "generatedAt": "bootstrap", + "commands": [], + "callbacks": [], + "pendingActions": [], + "onboardingSteps": ["Age Gate", "Account Setup", "Referral Prompt", "Username Link", "Promo Guidance", "Community Join"], + "modes": ["user", "admin"], + "qaControls": ["/qa_on", "/qa_off", "/qa_mode user|admin", "/qa_status"], + "rules": { + "telegramDefault": true, + "discordTesting": "postponed", + "providerFallbackOrder": ["deepseek", "gemini", "chatgpt"] + } +} diff --git a/qa/context/repo_info.json b/qa/context/repo_info.json new file mode 100644 index 0000000..84a3a1a --- /dev/null +++ b/qa/context/repo_info.json @@ -0,0 +1,7 @@ +{ + "repoPath": "/var/www/html/Runewager", + "branch": "main", + "systemdService": "runewager.service", + "entryFile": "index.js", + "telegramDefault": true +} diff --git a/qa/state/provider_status.json b/qa/state/provider_status.json new file mode 100644 index 0000000..f92d26d --- /dev/null +++ b/qa/state/provider_status.json @@ -0,0 +1,11 @@ +{ + "current": "deepseek", + "providers": { + "deepseek": { "status": "ready", "cooldownUntil": 0 }, + "gemini": { "status": "ready", "cooldownUntil": 0 }, + "chatgpt": { "status": "ready", "cooldownUntil": 0 } + }, + "retryAfterMs": 60000, + "resetIntervalMs": 600000, + "updatedAt": 0 +}