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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,11 @@ RW_DISCORD_SUPPORT=https://discord.com/channels/1100486422395355197/124918206729
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

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

Expand All @@ -221,7 +222,10 @@ Pending-action timeout handling is enforced in the text input router: each pendi
## 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 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, `BOT_PRIVACY_MODE`).


- Promo-level cooldown and claim-limit checks in eligibility evaluation.
Expand Down Expand Up @@ -325,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.
Expand Down
19 changes: 18 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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 };
}
Expand Down
244 changes: 191 additions & 53 deletions test/smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,39 @@ 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<string> 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) {
const files = [];
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) {
Expand All @@ -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<string> 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) {
Expand Down Expand Up @@ -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 (_) {
Expand All @@ -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.
Expand Down Expand Up @@ -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();
Expand All @@ -364,3 +397,108 @@ 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 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) => 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', () => {
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');
const declaredCommands = extractCommandHandlerNames(source);
for (const cmd of mustExist.filter((c) => c !== 'start')) {
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', () => {
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', 'BOT_PRIVACY_MODE',
];

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(', ')}`);
});
Loading