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
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion RUNEWAGER_FUNCTIONALITY_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)

Expand Down
33 changes: 27 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions test/unit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
// ---------------------------------------------------------------------------
Expand Down