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..8688467 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.) 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/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) // ---------------------------------------------------------------------------