Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions RUNEWAGER_FUNCTIONALITY_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
169 changes: 167 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');


/**
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
21 changes: 21 additions & 0 deletions qa/README_QA.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions qa/context/bot_capabilities.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
7 changes: 7 additions & 0 deletions qa/context/repo_info.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"repoPath": "/var/www/html/Runewager",
"branch": "main",
"systemdService": "runewager.service",
"entryFile": "index.js",
"telegramDefault": true
}
11 changes: 11 additions & 0 deletions qa/state/provider_status.json
Original file line number Diff line number Diff line change
@@ -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
}