From 90cbcd03302b21287efc617bd18dad911766948a Mon Sep 17 00:00:00 2001 From: GambleCodez Affiliates Date: Thu, 26 Feb 2026 15:58:31 -0600 Subject: [PATCH 1/2] =?UTF-8?q?Runewager=20Bot=20Upgrade=202.0=20=E2=80=94?= =?UTF-8?q?=20full=20audit,=20full=20implementation,=20full=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 ++++ CLAUDE.md | 7 ++++ RUNEWAGER_FUNCTIONALITY_MAP.md | 7 ++-- index.js | 33 ++++++++++++++---- test/smoke.test.js | 64 ++++++++++++++++++++++++++++++++++ test/unit.test.js | 45 ++++++++++++++++++++++++ 6 files changed, 154 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 570902f..d415264 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,9 @@ RW_DISCORD_SUPPORT=https://discord.com/channels/1100486422395355197/124918206729 TELEGRAM_CHANNEL_ID="-1002648883359" TELEGRAM_GROUP_ID="-1002400589513" TIPS_GROUP="-1002400589513" + +TELEGRAM_CHANNEL_LINK=https://t.me/runewager +TELEGRAM_GROUP_LINK=https://t.me/runewagerchat +HTTPS_KEY_PATH= +HTTPS_CERT_PATH= +BOT_PRIVACY_MODE=private diff --git a/CLAUDE.md b/CLAUDE.md index bf7405a..cc30da5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,13 @@ You are the Runewager Bot Ops Commander. You manage the full lifecycle of the Ru - Health monitoring after deployment - Proactive improvements +## Runewager 2.0 Agent Contract (Map-First) +- Treat `RUNEWAGER_FUNCTIONALITY_MAP.md` as the operational source of truth before and after every change. +- Implement functionality in code first, then immediately sync map/docs to match real behavior. +- Never ship doc-only behavior claims that are not implemented and tested. +- Before marking any task done: run tests/smoke checks, verify command↔callback↔menu reachability, and confirm pending-state recovery paths. +- Any added/removed flow must be reflected in `RUNEWAGER_FUNCTIONALITY_MAP.md` in the same change set. + ## Workflow Patterns 1. git pull origin main 2. Install dependencies as needed (npm install / pip install -r requirements.txt) diff --git a/RUNEWAGER_FUNCTIONALITY_MAP.md b/RUNEWAGER_FUNCTIONALITY_MAP.md index 2995f2b..0f7ba94 100644 --- a/RUNEWAGER_FUNCTIONALITY_MAP.md +++ b/RUNEWAGER_FUNCTIONALITY_MAP.md @@ -208,7 +208,7 @@ Configured/observed time behaviors: - Periodic persistence intervals and scheduler timers (content drops, maintenance timers). - Giveaway timers drive auto finalization and countdown behavior. -No universal pendingAction timeout/auto-cancel was detected for all user/admin input flows. +Pending-action timeout handling is enforced in the text input router: each pending state receives/retains `createdAt`, expires after 15 minutes, clears state, and returns a Main Menu recovery panel. Command escapes (`/cancel`, `/menu`, `/start`, `/help`) also clear pending states safely. ## 17. Validation Rules (username, discord, etc.) @@ -221,7 +221,10 @@ No universal pendingAction timeout/auto-cancel was detected for all user/admin i ## 18. Rate Limits & Cooldowns ### Verification controls -- Smoke test now enforces `REGISTERED_COMMANDS` parity with `bot.command(...)` handlers (except `start`, handled by `bot.start`). +- Smoke test enforces `REGISTERED_COMMANDS` parity with `bot.command(...)` handlers (except `start`, handled by `bot.start`). +- Callback coverage smoke check ignores catch-all callback fallbacks (`bot.action(/.*/)`), so button coverage must be satisfied by literal handlers or meaningful regex families. +- Smoke checks assert presence of critical 2.0 command families (onboarding/promo/giveaway/content-drops/admin tools) and pending-action escape routes (`/cancel`, `/menu`, `/start`, `/help`). +- Smoke checks verify `.env.example` documents core runtime configuration keys used by production flows (Telegram links, HTTPS cert/key paths, mini-app URLs, Discord links). - Promo-level cooldown and claim-limit checks in eligibility evaluation. diff --git a/index.js b/index.js index d4b7685..fd70aeb 100644 --- a/index.js +++ b/index.js @@ -380,6 +380,30 @@ const NON_USERNAME_WORDS = new Set([ // Slash commands implemented in this file. Used for unknown-command fallback. const PENDING_ACTION_TIMEOUT_MS = 15 * 60 * 1000; // 15m timeout for wait-for-input states +/** + * Evaluate and normalize a user's pendingAction timeout metadata. + * + * Returns an object with: + * - hadPending: whether a pending action existed + * - expired: whether it was cleared due to timeout + * - expiredType: pending action type cleared (if expired) + */ +function evaluatePendingActionTimeout(user, now = Date.now()) { + if (!user || !user.pendingAction) { + return { hadPending: false, expired: false, expiredType: null }; + } + + if (!user.pendingAction.createdAt) user.pendingAction.createdAt = now; + + if ((now - Number(user.pendingAction.createdAt || 0)) <= PENDING_ACTION_TIMEOUT_MS) { + return { hadPending: true, expired: false, expiredType: null }; + } + + const expiredType = user.pendingAction.type || 'current action'; + user.pendingAction = null; + return { hadPending: true, expired: true, expiredType }; +} + const REGISTERED_COMMANDS = new Set([ 'A', 'a', @@ -9187,13 +9211,10 @@ bot.on('text', async (ctx) => { const text = (ctx.message.text || '').trim(); - if (user.pendingAction && !user.pendingAction.createdAt) user.pendingAction.createdAt = Date.now(); - - if (user.pendingAction && (Date.now() - Number(user.pendingAction.createdAt || 0)) > PENDING_ACTION_TIMEOUT_MS) { - const expiredType = user.pendingAction.type || 'current action'; - user.pendingAction = null; + const pendingTimeoutState = evaluatePendingActionTimeout(user); + if (pendingTimeoutState.expired) { await ctx.reply( - `⏱️ Your pending step (${expiredType}) timed out after 15 minutes. Please start again from the menu.`, + `⏱️ Your pending step (${pendingTimeoutState.expiredType}) timed out after 15 minutes. Please start again from the menu.`, Markup.inlineKeyboard([[Markup.button.callback('🏠 Main Menu', 'to_main_menu')], [Markup.button.callback('❌ Cancel', 'to_main_menu')]]), ); return; diff --git a/test/smoke.test.js b/test/smoke.test.js index 6daa561..967fd90 100644 --- a/test/smoke.test.js +++ b/test/smoke.test.js @@ -364,3 +364,67 @@ test('REGISTERED_COMMANDS stays in sync with command handlers', () => { assert.deepEqual(missingInRegistered, [], `Commands missing from REGISTERED_COMMANDS: ${missingInRegistered.join(', ')}`); assert.deepEqual(extraInRegistered, [], `REGISTERED_COMMANDS entries without handlers: ${extraInRegistered.join(', ')}`); }); + + +test('catch-all callback fallback exists and is not used for coverage matching', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + const catchAllMatches = Array.from(source.matchAll(/bot\.action\(\s*\/\.\*\//g)); + assert.ok(catchAllMatches.length >= 1, 'Expected a catch-all bot.action(/.*/) fallback handler'); + + const patterns = extractActionRegexPatterns(source); + assert.ok(!patterns.some((rx) => rx.source === '.*' || rx.source === '.+'), 'Catch-all regex must not be counted as real callback coverage'); +}); + +test('critical 2.0 command families are registered and wired', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + const registered = extractRegisteredCommands(source); + assert.ok(registered, 'REGISTERED_COMMANDS block not found'); + + const mustExist = [ + // onboarding/help + 'start', 'help', 'walkthrough', 'link', 'linkrunewager', 'fixaccount', 'stuck', + // promo + 'promo', 'bonus', 'promocheck', 'scan_eligibility', 'setpromo', 'pmapprove', 'pmdeny', + // giveaway + 'giveaway', 'join', 'eligible', 'mygiveaways', 'start_giveaway', 'testgiveaway', 'pick_winner', + // content drops + broadcasts + 'tips', 'tipadd', 'tiptest', 'announce', 'broadcast_retry', 'broadcast_failed', + // admin ops + diagnostics + 'admin', 'health', 'logs', 'admin_backup', 'verify_bot_setup', 'sshv', 'testall', + ]; + + const missing = mustExist.filter((cmd) => !registered.has(cmd)); + assert.deepEqual(missing, [], `Missing critical registered commands: ${missing.join(', ')}`); + + assert.ok(source.includes('bot.start('), 'Expected bot.start handler for onboarding entry'); + for (const cmd of mustExist.filter((c) => c !== 'start')) { + const hasDirect = source.includes(`bot.command('${cmd}'`) || source.includes(`bot.command("${cmd}"`); + const hasWrapped = source.includes(`registerCommand('${cmd}'`) || source.includes(`registerCommand("${cmd}"`); + assert.ok(hasDirect || hasWrapped, `Expected handler declaration for /${cmd}`); + } +}); + +test('pending-action global escapes include cancel and main-menu routes', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + assert.match(source, /if \(user\.pendingAction && text\.startsWith\('\/'\)\)/, 'Expected slash-command escape block while pending'); + assert.match(source, /cmd === 'cancel'/, 'Expected /cancel escape for pending actions'); + assert.match(source, /cmd === 'menu' \|\| cmd === 'start' \|\| cmd === 'help'/, 'Expected /menu\/start\/help main-menu escapes for pending actions'); + assert.match(source, /PENDING_ACTION_TIMEOUT_MS\s*=\s*15 \* 60 \* 1000/, 'Expected global pending action timeout definition'); +}); + +test('.env.example documents key runtime env vars used by core flows', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + const envExample = fs.readFileSync(path.resolve(__dirname, '..', '.env.example'), 'utf8'); + const documented = new Set(Array.from(envExample.matchAll(/^([A-Z0-9_]+)=/gm), (m) => m[1])); + const core = [ + 'BOT_TOKEN', 'ADMIN_IDS', 'DEVICE', 'PORT', + 'RW_DISCORD_SUPPORT', 'RW_DISCORD_JOIN', 'RW_DISCORD_LINK', + 'TELEGRAM_CHANNEL_ID', 'TELEGRAM_GROUP_ID', 'TELEGRAM_CHANNEL_LINK', 'TELEGRAM_GROUP_LINK', + 'MINI_APP_PLAY_URL', 'MINI_APP_PROFILE_URL', 'MINI_APP_CLAIM_URL', + 'PROMO_ENTRY_IMAGE_URL', 'DISCORD_VERIFY_IMAGE_URL', 'DISCORD_CODE_GENERATION_IMAGE_URL', 'INTRO_GIF_URL', + 'HTTPS_KEY_PATH', 'HTTPS_CERT_PATH', 'WEBAPP_HMAC_KEY', 'TIPS_GROUP', + ]; + + const missingDoc = core.filter((k) => source.includes(`process.env.${k}`) && !documented.has(k)); + assert.deepEqual(missingDoc, [], `Missing .env.example entries for core runtime vars: ${missingDoc.join(', ')}`); +}); diff --git a/test/unit.test.js b/test/unit.test.js index 51e5c9c..8e6183c 100644 --- a/test/unit.test.js +++ b/test/unit.test.js @@ -174,6 +174,23 @@ function getNextOnboardingStep(user) { return 5; } + +function evaluatePendingActionTimeout(user, now = Date.now()) { + if (!user || !user.pendingAction) { + return { hadPending: false, expired: false, expiredType: null }; + } + + if (!user.pendingAction.createdAt) user.pendingAction.createdAt = now; + + if ((now - Number(user.pendingAction.createdAt || 0)) <= 15 * 60 * 1000) { + return { hadPending: true, expired: false, expiredType: null }; + } + + const expiredType = user.pendingAction.type || 'current action'; + user.pendingAction = null; + return { hadPending: true, expired: true, expiredType }; +} + // --------------------------------------------------------------------------- // Bonus status state machine // --------------------------------------------------------------------------- @@ -328,6 +345,34 @@ test('onboarding step 5 (done) when all steps complete', () => { assert.equal(getNextOnboardingStep(user), 5); }); + + +test('pending action timeout seeds createdAt on first check', () => { + const now = 1700000000000; + const user = { pendingAction: { type: 'await_tip_add_text' } }; + const out = evaluatePendingActionTimeout(user, now); + assert.equal(out.expired, false); + assert.equal(out.hadPending, true); + assert.equal(user.pendingAction.createdAt, now); +}); + +test('pending action timeout expires after 15 minutes and clears state', () => { + const now = 1700000900001; + const user = { pendingAction: { type: 'await_tip_add_text', createdAt: now - (15 * 60 * 1000) - 1 } }; + const out = evaluatePendingActionTimeout(user, now); + assert.equal(out.expired, true); + assert.equal(out.expiredType, 'await_tip_add_text'); + assert.equal(user.pendingAction, null); +}); + +test('pending action timeout uses fallback type label when missing', () => { + const now = 1700000900001; + const user = { pendingAction: { createdAt: now - (15 * 60 * 1000) - 1 } }; + const out = evaluatePendingActionTimeout(user, now); + assert.equal(out.expired, true); + assert.equal(out.expiredType, 'current action'); +}); + // --------------------------------------------------------------------------- // Promo claim limit check (pure logic) // --------------------------------------------------------------------------- From f8fdbae59dcb9c4b65fcad7ac27c5ec8e021b211 Mon Sep 17 00:00:00 2001 From: GambleCodez Affiliates Date: Thu, 26 Feb 2026 16:11:18 -0600 Subject: [PATCH 2/2] Integrated all review findings: pendingAction labels, docstrings, boundary tests, env cleanup, catch-all detection, command handler detection, full map/doc sync --- .env.example | 8 +- CLAUDE.md | 1 + RUNEWAGER_FUNCTIONALITY_MAP.md | 12 +- index.js | 19 +++- test/smoke.test.js | 194 +++++++++++++++++++++++---------- test/unit.test.js | 32 +++++- 6 files changed, 195 insertions(+), 71 deletions(-) diff --git a/.env.example b/.env.example index d415264..c9faf03 100644 --- a/.env.example +++ b/.env.example @@ -26,8 +26,10 @@ TELEGRAM_CHANNEL_ID="-1002648883359" TELEGRAM_GROUP_ID="-1002400589513" TIPS_GROUP="-1002400589513" +# BOT_PRIVACY_MODE=private|public — controls whether bot receives all group messages. +BOT_PRIVACY_MODE=private +HTTPS_CERT_PATH= +HTTPS_KEY_PATH= TELEGRAM_CHANNEL_LINK=https://t.me/runewager TELEGRAM_GROUP_LINK=https://t.me/runewagerchat -HTTPS_KEY_PATH= -HTTPS_CERT_PATH= -BOT_PRIVACY_MODE=private + diff --git a/CLAUDE.md b/CLAUDE.md index cc30da5..7cf1ebe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,7 @@ You are the Runewager Bot Ops Commander. You manage the full lifecycle of the Ru - Never ship doc-only behavior claims that are not implemented and tested. - Before marking any task done: run tests/smoke checks, verify command↔callback↔menu reachability, and confirm pending-state recovery paths. - Any added/removed flow must be reflected in `RUNEWAGER_FUNCTIONALITY_MAP.md` in the same change set. +- Verify each review finding against current code before changing it; do not apply blind fixes. ## Workflow Patterns 1. git pull origin main diff --git a/RUNEWAGER_FUNCTIONALITY_MAP.md b/RUNEWAGER_FUNCTIONALITY_MAP.md index 0f7ba94..c9836f1 100644 --- a/RUNEWAGER_FUNCTIONALITY_MAP.md +++ b/RUNEWAGER_FUNCTIONALITY_MAP.md @@ -119,7 +119,8 @@ The bot uses `user.pendingAction.type` as its input state machine. Key families: - **Forward registration**: `await_register_chat_forward`. ### Timeouts -- Global pending-action timeout now enforced in text flow: `PENDING_ACTION_TIMEOUT_MS = 15 minutes`; expired wait states are reset with a user-facing timeout message and `Main Menu` recovery buttons. +- Global pending-action timeout is enforced in text flow (`PENDING_ACTION_TIMEOUT_MS = 15 minutes`). Timeout UI uses user-facing labels (via `ACTION_LABELS`) rather than raw internal pendingAction keys. +- Boundary rule: a pending action exactly 15 minutes old does **not** expire (`<=` check); expiration starts only after crossing 15 minutes. - SSHV sessions have TTL-based GC (`SSHV_SESSION_TTL_MS`), with stale-state normalization and recovery. ## 8. Fallback Logic & Error Handling @@ -208,7 +209,7 @@ Configured/observed time behaviors: - Periodic persistence intervals and scheduler timers (content drops, maintenance timers). - Giveaway timers drive auto finalization and countdown behavior. -Pending-action timeout handling is enforced in the text input router: each pending state receives/retains `createdAt`, expires after 15 minutes, clears state, and returns a Main Menu recovery panel. Command escapes (`/cancel`, `/menu`, `/start`, `/help`) also clear pending states safely. +Pending-action timeout handling is enforced in the text input router: each pending state receives/retains `createdAt`, expires only when older than 15 minutes (exactly 15 minutes remains valid), clears state, and returns a Main Menu recovery panel. Timeout messages use user-facing labels from `ACTION_LABELS` where available, with `current action` fallback. Command escapes (`/cancel`, `/menu`, `/start`, `/help`) also clear pending states safely. ## 17. Validation Rules (username, discord, etc.) @@ -221,10 +222,10 @@ Pending-action timeout handling is enforced in the text input router: each pendi ## 18. Rate Limits & Cooldowns ### Verification controls -- Smoke test enforces `REGISTERED_COMMANDS` parity with `bot.command(...)` handlers (except `start`, handled by `bot.start`). -- Callback coverage smoke check ignores catch-all callback fallbacks (`bot.action(/.*/)`), so button coverage must be satisfied by literal handlers or meaningful regex families. +- Smoke test enforces `REGISTERED_COMMANDS` parity with command handlers detected from `bot.command(...)` and `registerCommand(...)`, including single/double/backtick literals and simple const-driven names (except `start`, handled by `bot.start`). +- Callback coverage smoke check ignores catch-all callback fallbacks using robust pattern detection (`/.*/`, `/^.*$/`, `/.+/` with optional flags/whitespace), so button coverage must be satisfied by literal handlers or meaningful regex families. - Smoke checks assert presence of critical 2.0 command families (onboarding/promo/giveaway/content-drops/admin tools) and pending-action escape routes (`/cancel`, `/menu`, `/start`, `/help`). -- Smoke checks verify `.env.example` documents core runtime configuration keys used by production flows (Telegram links, HTTPS cert/key paths, mini-app URLs, Discord links). +- Smoke checks verify `.env.example` documents core runtime configuration keys used by production flows (Telegram links, HTTPS cert/key paths, mini-app URLs, Discord links, `BOT_PRIVACY_MODE`). - Promo-level cooldown and claim-limit checks in eligibility evaluation. @@ -328,6 +329,7 @@ Admin starts wizard (gwiz) - 2026-02-26: Re-verified removal of `/language` command/callback surface, reran smoke/full tests, and synchronized agent-contract wording in `CLAUDE.md` with map-first/update-map/verify requirements. - 2026-02-26: Completed a menu-integrity pass by adding explicit `Main Menu` + `Cancel` exits to admin category keyboards and settings submenu, then revalidated smoke/full tests. - 2026-02-26: Added global 15-minute pending-action timeout and slash-command escape fallbacks (`/cancel`, `/menu`, `/start`, `/help`) for wait-for-input state safety. +- 2026-02-26: Added user-facing pending-action timeout labels (`ACTION_LABELS`), documented exact-15-minute boundary semantics, and hardened smoke detection for catch-all callbacks and command declaration forms. - 2026-02-26: Full giveaway system upgrade completed: expanded Admin Giveaway menu (start/defaults/edit/extend/end/participants/group-linking/announcement builder/payout manager/test), added confirmation-gated 2-minute Test Giveaway flow (3 winners, 0 SC, 10 fake participants, admin winner guarantee), enforced test isolation from persistence/lists/history, added pinned announcement failure reporting, richer announcement content (Join + Open Bot + tips + referral boost + live counters), weighted winner selection with referral boost simulation, and bulk payout manager flow with post-payout history cleanup for real giveaways. diff --git a/index.js b/index.js index fd70aeb..dfc01dc 100644 --- a/index.js +++ b/index.js @@ -380,6 +380,23 @@ const NON_USERNAME_WORDS = new Set([ // Slash commands implemented in this file. Used for unknown-command fallback. const PENDING_ACTION_TIMEOUT_MS = 15 * 60 * 1000; // 15m timeout for wait-for-input states + +// Human-friendly labels for pending action keys shown in timeout/error UI. +const ACTION_LABELS = { + await_tip_add_text: 'add tip text', + await_tip_edit_text: 'edit tip text', + await_tip_settings_interval: 'tip schedule interval', + await_tip_amount: 'tip amount', + await_runewager_username: 'Runewager username entry', + await_username_confirm: 'username confirmation', + await_announcement_text: 'announcement message', + await_announcement_action: 'announcement action', + await_sshv_command: 'SSHV command', + await_sshv_editor_content: 'SSHV editor content', + await_sshv_danger_confirm: 'dangerous SSHV confirmation', + await_register_chat_forward: 'chat registration forward', +}; + /** * Evaluate and normalize a user's pendingAction timeout metadata. * @@ -399,7 +416,7 @@ function evaluatePendingActionTimeout(user, now = Date.now()) { return { hadPending: true, expired: false, expiredType: null }; } - const expiredType = user.pendingAction.type || 'current action'; + const expiredType = ACTION_LABELS[user.pendingAction.type] || 'current action'; user.pendingAction = null; return { hadPending: true, expired: true, expiredType }; } diff --git a/test/smoke.test.js b/test/smoke.test.js index 967fd90..ca29648 100644 --- a/test/smoke.test.js +++ b/test/smoke.test.js @@ -5,23 +5,19 @@ const fs = require('node:fs'); const path = require('node:path'); /** - - * collectJsFiles executes its scoped Runewager logic and participates in menu/command or utility flow composition. - - * Parameters: See the function signature for exact argument names and accepted values. - - * Returns: Returns the computed value or a Promise resolving to the operation result; may return void for side-effect handlers. - - * Side effects: May mutate runtime stores, pendingAction state, menu state, persistence files, logs, and callback progression. - - * Validation/safety: Uses existing guard utilities (admin checks, input checks, path checks, cooldown checks) where applicable. - - * Timeouts/fallbacks: Timeout and fallback behavior are controlled by the calling flow and global handler/state machine conventions. - - * Errors: Surfaces user-facing error replies and/or logs when inputs, permissions, or dependencies are invalid. - - * System fit: This function is part of the Runewager command/callback/state orchestration pipeline. - + * Recursively collect JavaScript files under `rootDir` for static smoke checks. + * + * Parameters: + * - rootDir: absolute or relative directory path to scan. + * + * Returns: + * - Array of `*.js` file paths (excluding known large/runtime folders). + * + * Side effects: + * - None beyond filesystem reads. + * + * Errors: + * - Read/stat errors for individual entries are tolerated and skipped. */ function collectJsFiles(rootDir) { @@ -29,23 +25,19 @@ function collectJsFiles(rootDir) { const skipDirs = new Set(['.git', 'node_modules', 'data', 'logs', 'test']); /** - - * walk executes its scoped Runewager logic and participates in menu/command or utility flow composition. - - * Parameters: See the function signature for exact argument names and accepted values. - - * Returns: Returns the computed value or a Promise resolving to the operation result; may return void for side-effect handlers. - - * Side effects: May mutate runtime stores, pendingAction state, menu state, persistence files, logs, and callback progression. - - * Validation/safety: Uses existing guard utilities (admin checks, input checks, path checks, cooldown checks) where applicable. - - * Timeouts/fallbacks: Timeout and fallback behavior are controlled by the calling flow and global handler/state machine conventions. - - * Errors: Surfaces user-facing error replies and/or logs when inputs, permissions, or dependencies are invalid. - - * System fit: This function is part of the Runewager command/callback/state orchestration pipeline. - + * Depth-first directory walker used by collectJsFiles. + * + * Parameters: + * - dir: directory path to walk. + * + * Returns: + * - void (pushes discovered files into `files`). + * + * Side effects: + * - Mutates local `files` array by appending JS file paths. + * + * Errors: + * - Entry stat/read errors are ignored so scanning continues. */ function walk(dir) { @@ -71,23 +63,23 @@ function collectJsFiles(rootDir) { } /** - - * extractLiteralIds executes its scoped Runewager logic and participates in menu/command or utility flow composition. - - * Parameters: See the function signature for exact argument names and accepted values. - - * Returns: Returns the computed value or a Promise resolving to the operation result; may return void for side-effect handlers. - - * Side effects: May mutate runtime stores, pendingAction state, menu state, persistence files, logs, and callback progression. - - * Validation/safety: Uses existing guard utilities (admin checks, input checks, path checks, cooldown checks) where applicable. - - * Timeouts/fallbacks: Timeout and fallback behavior are controlled by the calling flow and global handler/state machine conventions. - - * Errors: Surfaces user-facing error replies and/or logs when inputs, permissions, or dependencies are invalid. - - * System fit: This function is part of the Runewager command/callback/state orchestration pipeline. - + * Extract literal callback IDs or action IDs from source text. + * + * Parameters: + * - source: JS source string. + * - kind: 'callback' for Markup.button.callback IDs, 'action' for bot.action literal IDs. + * + * Returns: + * - Set of discovered literal IDs. Template literals with interpolation are skipped. + * + * Side effects: + * - None. + * + * Errors: + * - Invalid source shapes simply produce an empty result. + * + * Example: + * - extractLiteralIds("bot.action('x', fn)", 'action') -> Set{'x'} */ function extractLiteralIds(source, kind) { @@ -196,7 +188,7 @@ function extractActionRegexPatterns(source) { const { patternSource, flags, end } = parsed; // Ignore generic catch-all handlers that would make coverage meaningless. - if (patternSource !== '.*' && patternSource !== '.+') { + if (!isCatchAllRegexPattern(patternSource)) { try { patterns.push(new RegExp(patternSource, flags)); } catch (_) { @@ -210,6 +202,47 @@ function extractActionRegexPatterns(source) { return patterns; } + +/** + * Determine whether a regex source represents a generic catch-all callback matcher. + * Supports `.*`, `^.*$`, and `.+` with optional grouping/anchors. + */ +function isCatchAllRegexPattern(patternSource) { + const compact = String(patternSource || '').replace(/\s+/g, ''); + return compact === '.*' || compact === '^.*$' || compact === '.+' || compact === '^.+$'; +} + +/** + * Extract command handler declarations from source, supporting quoted, backtick, + * and simple constant-based command names. + */ +function extractCommandHandlerNames(source) { + const names = new Set(); + + // Resolve simple constants: const HELP = 'help'; const GW = `giveaway`; + const constMap = new Map(); + for (const m of source.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(["'`])((?:\\.|(?!\2).)*)\2\s*;/g)) { + const value = m[3]; + if (!value.includes('${')) constMap.set(m[1], value); + } + + const commandCall = /\b(?:bot\.command|registerCommand)\(\s*([^,\)]+)\s*[,\)]/g; + let match; + while ((match = commandCall.exec(source)) !== null) { + const token = String(match[1] || '').trim(); + const q = token.match(/^(["'`])((?:\\.|(?!\1).)*)\1$/); + if (q) { + if (!q[2].includes('${')) names.add(q[2]); + continue; + } + const id = token.match(/^[A-Za-z_$][\w$]*$/); + if (id && constMap.has(id[0])) names.add(constMap.get(id[0])); + } + + return names; +} + + /** * parseStringLiterals executes its scoped Runewager logic and participates in menu/command or utility flow composition. @@ -355,7 +388,7 @@ test('REGISTERED_COMMANDS stays in sync with command handlers', () => { const registered = extractRegisteredCommands(source); assert.ok(registered, 'REGISTERED_COMMANDS block not found'); - const commandHandlers = new Set(Array.from(source.matchAll(/bot\.command\(\s*(["'`])((?:\\.|(?!\1).)*)\1/g), (m) => m[2])); + const commandHandlers = extractCommandHandlerNames(source); const allowedRegisteredOnly = new Set(['start']); // handled by bot.start(...), not bot.command('start') const missingInRegistered = Array.from(commandHandlers).filter((cmd) => !registered.has(cmd)).sort(); @@ -368,11 +401,32 @@ test('REGISTERED_COMMANDS stays in sync with command handlers', () => { test('catch-all callback fallback exists and is not used for coverage matching', () => { const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); - const catchAllMatches = Array.from(source.matchAll(/bot\.action\(\s*\/\.\*\//g)); - assert.ok(catchAllMatches.length >= 1, 'Expected a catch-all bot.action(/.*/) fallback handler'); + const actionRegexLiterals = Array.from(source.matchAll(/bot\.action\(\s*\/((?:\.|[^/])+)\/([dgimsuvy]*)/g), (m) => ({ src: m[1], flags: m[2] })); + assert.ok(actionRegexLiterals.some((x) => isCatchAllRegexPattern(x.src)), 'Expected at least one catch-all bot.action(regex) fallback handler'); const patterns = extractActionRegexPatterns(source); - assert.ok(!patterns.some((rx) => rx.source === '.*' || rx.source === '.+'), 'Catch-all regex must not be counted as real callback coverage'); + assert.ok(!patterns.some((rx) => isCatchAllRegexPattern(rx.source)), 'Catch-all regex must not be counted as real callback coverage'); +}); + +test('extractActionRegexPatterns filters all catch-all regex forms', () => { + const fixture = [ + 'bot.action(/.*/, fn);', + 'bot.action(/^.*$/i, fn);', + 'bot.action(/.+/g, fn);', + 'bot.action(/gw_join_(\d+)/, fn);', + ].join('\n'); + + const patterns = extractActionRegexPatterns(fixture); + assert.ok(patterns.some((rx) => rx.source === 'gw_join_(\d+)'), 'Expected non-catch-all regex to remain'); + assert.ok(!patterns.some((rx) => isCatchAllRegexPattern(rx.source)), 'Catch-all regex patterns must be filtered out'); +}); + +test('catch-all detection recognizes supported regex forms', () => { + const cases = ['.*', '^.*$', '.+', '^.+$', ' ^ .* $ ']; + for (const c of cases) assert.equal(isCatchAllRegexPattern(c), true, `expected ${c} to be catch-all`); + for (const c of ['gw_join_(\d+)', 'help_page_(\d+)', 'promo_claim_(.+)']) { + assert.equal(isCatchAllRegexPattern(c), false, `expected ${c} not to be catch-all`); + } }); test('critical 2.0 command families are registered and wired', () => { @@ -397,11 +451,31 @@ test('critical 2.0 command families are registered and wired', () => { assert.deepEqual(missing, [], `Missing critical registered commands: ${missing.join(', ')}`); assert.ok(source.includes('bot.start('), 'Expected bot.start handler for onboarding entry'); + const declaredCommands = extractCommandHandlerNames(source); for (const cmd of mustExist.filter((c) => c !== 'start')) { - const hasDirect = source.includes(`bot.command('${cmd}'`) || source.includes(`bot.command("${cmd}"`); - const hasWrapped = source.includes(`registerCommand('${cmd}'`) || source.includes(`registerCommand("${cmd}"`); - assert.ok(hasDirect || hasWrapped, `Expected handler declaration for /${cmd}`); + assert.ok(declaredCommands.has(cmd), `Expected handler declaration for /${cmd}`); + } +}); + +test('command handler detection supports single/double/backtick and const-driven names', () => { + const fixture = [ + "const CMD_ONE = 'alpha';", + 'const CMD_TWO = "beta";', + 'const CMD_THREE = `gamma`;', + "bot.command('delta', fn);", + 'bot.command("epsilon", fn);', + 'bot.command(`zeta`, fn);', + 'registerCommand(CMD_ONE, fn);', + 'bot.command(CMD_TWO, fn);', + 'registerCommand(CMD_THREE, fn);', + 'bot.command(`skip_${x}`, fn);', + ].join('\n'); + + const found = extractCommandHandlerNames(fixture); + for (const expected of ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta']) { + assert.ok(found.has(expected), `Expected to find command: ${expected}`); } + assert.ok(!found.has('skip_${x}'), 'Interpolated template literal should not be treated as a concrete command'); }); test('pending-action global escapes include cancel and main-menu routes', () => { @@ -422,7 +496,7 @@ test('.env.example documents key runtime env vars used by core flows', () => { 'TELEGRAM_CHANNEL_ID', 'TELEGRAM_GROUP_ID', 'TELEGRAM_CHANNEL_LINK', 'TELEGRAM_GROUP_LINK', 'MINI_APP_PLAY_URL', 'MINI_APP_PROFILE_URL', 'MINI_APP_CLAIM_URL', 'PROMO_ENTRY_IMAGE_URL', 'DISCORD_VERIFY_IMAGE_URL', 'DISCORD_CODE_GENERATION_IMAGE_URL', 'INTRO_GIF_URL', - 'HTTPS_KEY_PATH', 'HTTPS_CERT_PATH', 'WEBAPP_HMAC_KEY', 'TIPS_GROUP', + 'HTTPS_KEY_PATH', 'HTTPS_CERT_PATH', 'WEBAPP_HMAC_KEY', 'TIPS_GROUP', 'BOT_PRIVACY_MODE', ]; const missingDoc = core.filter((k) => source.includes(`process.env.${k}`) && !documented.has(k)); diff --git a/test/unit.test.js b/test/unit.test.js index 8e6183c..405ae88 100644 --- a/test/unit.test.js +++ b/test/unit.test.js @@ -174,7 +174,26 @@ function getNextOnboardingStep(user) { return 5; } +const ACTION_LABELS = { + await_tip_add_text: 'add tip text', + await_tip_amount: 'tip amount', +}; +/** + * evaluatePendingActionTimeout normalizes and evaluates pending-action timeout metadata + * for a user object under test. + * + * Parameters: + * - user: object that may include `pendingAction` with `type` and `createdAt`. + * - now: optional epoch-milliseconds timestamp used as current time (defaults to Date.now()). + * + * Returns: + * - { hadPending: boolean, expired: boolean, expiredType: string|null } + * + * Mutation behavior: + * - If a pending action exists without `createdAt`, it mutates `user.pendingAction.createdAt = now`. + * - If pending action is expired (>15 minutes old), it mutates `user.pendingAction = null`. + */ function evaluatePendingActionTimeout(user, now = Date.now()) { if (!user || !user.pendingAction) { return { hadPending: false, expired: false, expiredType: null }; @@ -186,7 +205,7 @@ function evaluatePendingActionTimeout(user, now = Date.now()) { return { hadPending: true, expired: false, expiredType: null }; } - const expiredType = user.pendingAction.type || 'current action'; + const expiredType = ACTION_LABELS[user.pendingAction.type] || 'current action'; user.pendingAction = null; return { hadPending: true, expired: true, expiredType }; } @@ -356,12 +375,21 @@ test('pending action timeout seeds createdAt on first check', () => { assert.equal(user.pendingAction.createdAt, now); }); +test('pending action exactly 15 minutes old does not expire', () => { + const now = 1700000900000; + const user = { pendingAction: { type: 'await_tip_amount', createdAt: now - (15 * 60 * 1000) } }; + const out = evaluatePendingActionTimeout(user, now); + assert.equal(out.expired, false); + assert.equal(out.expiredType, null); + assert.equal(user.pendingAction.type, 'await_tip_amount'); +}); + test('pending action timeout expires after 15 minutes and clears state', () => { const now = 1700000900001; const user = { pendingAction: { type: 'await_tip_add_text', createdAt: now - (15 * 60 * 1000) - 1 } }; const out = evaluatePendingActionTimeout(user, now); assert.equal(out.expired, true); - assert.equal(out.expiredType, 'await_tip_add_text'); + assert.equal(out.expiredType, 'add tip text'); assert.equal(user.pendingAction, null); });