From 67f9a187d4fd88ca86e1ae35bbbff90a547a9446 Mon Sep 17 00:00:00 2001 From: GambleCodez Affiliates Date: Thu, 26 Feb 2026 16:33:22 -0600 Subject: [PATCH] Final upgrade: menu vanish fix, browser mode fix, Discord external links, group linking tools, TestAll engine, full navigation hardening, env/doc/detection sync --- .env.example | 24 ++- CLAUDE.md | 11 ++ RUNEWAGER_FUNCTIONALITY_MAP.md | 18 +- index.js | 321 +++++++++++++++++++++++++++++---- test/smoke.test.js | 266 +++++++++++++++++++++------ test/unit.test.js | 144 +++++++++++++++ 6 files changed, 678 insertions(+), 106 deletions(-) diff --git a/.env.example b/.env.example index 570902f..d8604d2 100644 --- a/.env.example +++ b/.env.example @@ -1,27 +1,33 @@ +ADMIN_IDS=YOUR_TELEGRAM_USER_ID AFFILIATE_SOURCE=GambleCodez ANNOUNCE_CHANNEL="-1002648883359" -DEVICE=vps -ADMIN_IDS=YOUR_TELEGRAM_USER_ID +BOTPRIVACYMODE="disabled" +# BOT_PRIVACY_MODE=private|public — controls whether bot receives all group messages. +BOT_PRIVACY_MODE=private BOT_TOKEN= -# WEBAPP_HMAC_KEY: 32-byte secret for HMAC-signing webapp payloads/CSRF tokens. -# Generate with: openssl rand -hex 32 (or openssl rand -base64 32). Keep secret; never commit real values. -WEBAPP_HMAC_KEY="" -TELEGRAM_BOT_TOKEN= +DEVICE=vps DISCORD_CODE_GENERATION_IMAGE_URL=https://raw.githubusercontent.com/gamblecodezcom/Runewager/main/images/discord_code_generation.png DISCORD_VERIFY_IMAGE_URL=https://raw.githubusercontent.com/gamblecodezcom/Runewager/main/images/discord_verify.png +HTTPS_CERT_PATH= +HTTPS_KEY_PATH= INTRO_GIF_URL= -PROMO_ENTRY_IMAGE_URL=https://raw.githubusercontent.com/gamblecodezcom/Runewager/main/images/promo_entry.png +LOG_LEVEL=info MAX_ANALYTICS_EVENTS=2000 MAX_ONBOARDING_STEPS_HISTORY=500 MINI_APP_CLAIM_URL=https://t.me/RuneWager_bot/claim MINI_APP_PLAY_URL=https://t.me/RuneWager_bot/Play MINI_APP_PROFILE_URL=https://t.me/RuneWager_bot/profile -# LOG_LEVEL valid values: debug, info, warn, error (default: info) -LOG_LEVEL=info PORT=3000 +PROMO_ENTRY_IMAGE_URL=https://raw.githubusercontent.com/gamblecodezcom/Runewager/main/images/promo_entry.png RW_DISCORD_JOIN=https://discord.gg/runewagers RW_DISCORD_LINK=https://discord.com/channels/1100486422395355197/1249181934811349052 RW_DISCORD_SUPPORT=https://discord.com/channels/1100486422395355197/1249182067296567338 +TELEGRAM_BOT_TOKEN= TELEGRAM_CHANNEL_ID="-1002648883359" +TELEGRAM_CHANNEL_LINK=https://t.me/runewager TELEGRAM_GROUP_ID="-1002400589513" +TELEGRAM_GROUP_LINK=https://t.me/runewagerchat TIPS_GROUP="-1002400589513" +# WEBAPP_HMAC_KEY: 32-byte secret for HMAC-signing webapp payloads/CSRF tokens. +# Generate with: openssl rand -hex 32 (or openssl rand -base64 32). Keep secret; never commit real values. +WEBAPP_HMAC_KEY="" diff --git a/CLAUDE.md b/CLAUDE.md index bf7405a..b2bc9d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,17 @@ 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. +- Verify each review finding against current code before changing it; do not apply blind fixes. +- 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.` + ## 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..5cc907f 100644 --- a/RUNEWAGER_FUNCTIONALITY_MAP.md +++ b/RUNEWAGER_FUNCTIONALITY_MAP.md @@ -46,6 +46,7 @@ Navigation is driven by inline menus plus command aliases. Persistent user/admin - `sendCommandError(...)` standardizes command failure messaging. - Menus use callback “back” paths (`to_main_menu`, `open_admin_dashboard`, `pamenu_back_*`). - 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. ## 4. User Menu Tree @@ -54,11 +55,13 @@ Navigation is driven by inline menus plus command aliases. Persistent user/admin - Includes onboarding/account actions, promo/bonus entry points, community links, giveaways, help, settings. ### Key user branches +- Play-link routing is deterministic: browser mode always returns external `https://gamble-codez.com/...`; telegram mode always returns `startapp` mini-app deep links. +- Discord links are browser-only via `getDiscordLink(...)` sanitation (no WebAppInfo/startapp wrappers). - **Profile & status**: `/profile`, quick status callbacks. - **Promo**: `menu_claim_bonus` → filtered eligible promos only. - **Giveaways**: `menu_giveaways` and `/mygiveaways` for active and personalized entries. - **Walkthrough/help**: paginated booklet (`help_page_N`) and walkthrough steps (`walk_next/back/done`). -- **Settings**: play mode + quick commands + tooltip toggles. +- **Settings**: play mode + quick commands + tooltip toggles + Group Linking Tools (link/view/remove/test permissions). ### Expected input & fallback - Non-command text when no pending state is mostly ignored except smart username detect. @@ -77,6 +80,7 @@ Navigation is driven by inline menus plus command aliases. Persistent user/admin - Content Drops manager shortcut ### Admin category menus +- TestAll engine (`/testall`) runs structured diagnostics across environment, data/stores, callbacks/commands, navigation helpers, giveaway/promo/content-drop, SSHV, and pendingAction timeout/label rules; summary line: `TestAll complete — X passed, Y warnings, Z failures.` - `admin_cat_giveaway`: start/test/status + persistent navigation row (`Admin Dashboard`, `Main Menu`, `Cancel`). - `admin_cat_promo`: full promo manager actions + guide + persistent navigation row (`Admin Dashboard`, `Main Menu`, `Cancel`). - `admin_cat_system`: health/version/verify/setup/backup/admin mode/testall/sshv + persistent navigation row (`Admin Dashboard`, `Main Menu`, `Cancel`). @@ -119,7 +123,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 @@ -184,6 +189,7 @@ Notes: ## 14. Permissions & Access Control - Admin identity is based on `ADMIN_IDS` env list. +- `.env.example` documents both `BOT_PRIVACY_MODE` and compatibility `BOTPRIVACYMODE`, plus Telegram/HTTPS/discovery keys used by runtime checks. - `requireAdmin(ctx)` gates command/callback execution. - Admin mode UI toggle changes visibility of admin UI, not true authorization. - Group approvals list controls where certain broadcast/group operations target. @@ -208,7 +214,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 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,7 +227,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 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. @@ -325,6 +334,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 d4b7685..4c8a6b8 100644 --- a/index.js +++ b/index.js @@ -24,15 +24,66 @@ if (!BOT_TOKEN) { throw new Error('Missing BOT_TOKEN (or TELEGRAM_BOT_TOKEN) in environment variables.'); } + +function unwrapTelegramUrl(url) { + const raw = String(url || '').trim(); + try { + const parsed = new URL(raw); + if (parsed.hostname === 't.me' || parsed.hostname === 'telegram.me') { + const embedded = parsed.searchParams.get('url'); + if (embedded) return embedded; + } + } catch (_) { /* ignore parse failure */ } + return raw; +} + +function getDiscordLink(url) { + const candidate = unwrapTelegramUrl(url); + try { + const parsed = new URL(candidate); + const host = parsed.hostname.toLowerCase(); + if ((host === 'discord.com' || host === 'www.discord.com' || host === 'discord.gg' || host === 'www.discord.gg') + && parsed.protocol === 'https:') { + return `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`; + } + } catch (_) { /* ignore */ } + return 'https://discord.gg/runewagers'; +} + +function getBrowserLink(route = 'play') { + const map = { + play: 'https://gamble-codez.com/runewager', + profile: 'https://gamble-codez.com/runewager/profile', + claim: 'https://gamble-codez.com/runewager/claim', + affiliate: 'https://gamble-codez.com/runewager/affiliate', + discord: LINKS ? LINKS.rwDiscordJoin : 'https://discord.gg/runewagers', + }; + return map[route] || map.play; +} + +function getWebAppLink(route = 'play') { + const map = { play: LINKS.miniAppPlay, profile: LINKS.miniAppProfile, claim: LINKS.miniAppClaim }; + return map[route] || LINKS.miniAppPlay; +} + +function getStartAppLink(route = 'play') { + return `${LINKS.miniAppPlay}?startapp=${route}`; +} + +function getPlayLink(user, route = 'play') { + const mode = user && user.settings ? user.settings.playMode : 'miniapp'; + return mode === 'browser' ? getBrowserLink(route) : getStartAppLink(route); +} + const LINKS = { // External browser links — always open outside Telegram runewagerSignup: 'https://www.runewager.com/r/GambleCodez', promoInput: 'https://www.runewager.com/affiliate', runewagerProfile: 'https://www.runewager.com/profile', // Discord links — always external browser, no Discord API used - rwDiscordSupport: process.env.RW_DISCORD_SUPPORT || 'https://discord.com/channels/1100486422395355197/1249182067296567338', - rwDiscordJoin: process.env.RW_DISCORD_JOIN || 'https://discord.gg/runewagers', - rwDiscordLink: process.env.RW_DISCORD_LINK || 'https://discord.com/channels/1100486422395355197/1249181934811349052', + rwDiscordSupport: getDiscordLink(process.env.RW_DISCORD_SUPPORT || 'https://discord.com/channels/1100486422395355197/1249182067296567338'), + rwDiscordJoin: getDiscordLink(process.env.RW_DISCORD_JOIN || 'https://discord.gg/runewagers'), + rwDiscordLink: getDiscordLink(process.env.RW_DISCORD_LINK || 'https://discord.com/channels/1100486422395355197/1249181934811349052'), // Telegram channels/groups — external links gczChannel: process.env.TELEGRAM_CHANNEL_LINK || 'https://t.me/GambleCodezDrops', gczGroup: process.env.TELEGRAM_GROUP_LINK || 'https://t.me/GambleCodezPrizeHub', @@ -380,6 +431,47 @@ 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. + * + * 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 = ACTION_LABELS[user.pendingAction.type] || 'current action'; + user.pendingAction = null; + return { hadPending: true, expired: true, expiredType }; +} + const REGISTERED_COMMANDS = new Set([ 'A', 'a', @@ -1162,6 +1254,8 @@ function createDefaultUser(user) { mainMenuChatId: null, adminMenuMsgId: null, adminMenuChatId: null, + lastMenuMsgId: null, + lastMenuChatId: null, ephemeralBonusMsgId: null, ephemeralBonusChatId: null, referralCount: 0, @@ -2413,8 +2507,10 @@ function promoButton() { */ -function miniAppPlayButton() { - return Markup.button.url('🎮 Open Runewager (Mini App)', LINKS.miniAppPlay); +function miniAppPlayButton(user = null, route = 'play') { + const target = getPlayLink(user, route); + const inBrowser = (user && user.settings && user.settings.playMode === 'browser'); + return Markup.button.url(inBrowser ? '🌐 Open Runewager (Browser)' : '🎮 Open Runewager (Mini App)', target); } /** @@ -2424,9 +2520,7 @@ function miniAppPlayButton() { * @param {object} user - user object for settings-aware button labels */ function mainMenuKeyboard(isAdminUser = false, page = 1, user = null) { - const playUrl = (user && user.settings && user.settings.playMode === 'browser') - ? LINKS.runewagerSignup - : LINKS.miniAppPlay; + const playUrl = getPlayLink(user, 'play'); if (page === 2) { // ── Page 2 — Community & Extras ────────────────────────────────────────── @@ -3084,6 +3178,7 @@ function settingsKeyboard(user) { [Markup.button.callback(playLabel, 'settings_toggle_playmode')], [Markup.button.callback(quickLabel, 'settings_toggle_quick_commands')], [Markup.button.callback(tooltipLabel, 'settings_toggle_tooltips')], + [Markup.button.callback('🔗 Group Linking Tools', 'settings_group_linking_tools')], [Markup.button.callback('⬅️ Back to Menu', 'to_main_menu')], [Markup.button.callback('❌ Cancel', 'to_main_menu')], ]); @@ -3202,7 +3297,6 @@ function adminGiveawayToolsKeyboard() { [Markup.button.callback('⏱ Extend Giveaway', 'admin_cmd_giveaway_status')], [Markup.button.callback('🛑 End Giveaway', 'admin_cmd_giveaway_status')], [Markup.button.callback('👥 View Participants', 'admin_cmd_giveaway_status')], - [Markup.button.callback('🔗 Group Linking Tools', 'admin_gw_group_linking')], [Markup.button.callback('📣 Announcement Builder', 'admin_cmd_announce_start')], [Markup.button.callback('💸 Winner Payout Manager', 'admin_gw_payout_manager')], [Markup.button.callback('🧪 Run Test Giveaway', 'admin_cmd_testgiveaway')], @@ -3572,6 +3666,35 @@ function formatTipForHtml(text) { */ +function getContextChatId(ctx) { + if (ctx && ctx.chat && ctx.chat.id) return ctx.chat.id; + if (ctx && ctx.callbackQuery && ctx.callbackQuery.message && ctx.callbackQuery.message.chat) return ctx.callbackQuery.message.chat.id; + return null; +} + +async function clearOldMenus(ctx, user = null) { + const u = user || getUser(ctx); + if (!u || !u.lastMenuMsgId || !u.lastMenuChatId) return; + try { + await ctx.telegram.deleteMessage(u.lastMenuChatId, u.lastMenuMsgId); + } catch (_) { + // ignore stale/missing menu errors + } + u.lastMenuMsgId = null; + u.lastMenuChatId = null; +} + +async function replyMenu(ctx, user, text, extra = {}) { + await clearOldMenus(ctx, user); + const sent = await ctx.reply(text, extra); + const chatId = sent && sent.chat ? sent.chat.id : getContextChatId(ctx); + if (user && sent && sent.message_id && chatId) { + user.lastMenuMsgId = sent.message_id; + user.lastMenuChatId = chatId; + } + return sent; +} + async function sendSettingsMenu(ctx, user) { const s = user.settings; const lines = [ @@ -3586,7 +3709,7 @@ async function sendSettingsMenu(ctx, user) { `ℹ️ Tooltips: *${s.tooltipsEnabled ? 'On ✅' : 'Off ☐'}*`, ` Shows extra guidance tips in the help booklet.`, ].join('\n'); - await ctx.reply(lines, { parse_mode: 'Markdown', ...settingsKeyboard(user) }); + await replyMenu(ctx, user, lines, { parse_mode: 'Markdown', ...settingsKeyboard(user) }); } /** @@ -3615,7 +3738,9 @@ async function sendMainMenu(ctx, user, page = 1) { const statusLine = buildUserStatusLine(user); const pageLabel = page === 2 ? ' — Page 2/2: Community & Extras' : ' — Page 1/2: Main Actions'; - await ctx.reply( + await replyMenu( + ctx, + user, `👋 Welcome${name} to the GambleCodez Runewager bot!${pageLabel}\n\n${statusLine}\n\nSelect an option below:`, mainMenuKeyboard(adminUser, page, user), ); @@ -6101,6 +6226,7 @@ bot.command('start_giveaway', async (ctx) => { bot.command('cancel', async (ctx) => { const user = getUser(ctx); clearPendingAction(user); + await clearOldMenus(ctx, user); await ctx.reply("Cancelled. Pending input flow cleared."); }); @@ -6397,7 +6523,9 @@ bot.command('startapp', safeStepHandler('startapp', async (ctx) => { const allowed = new Set(['play', 'profile', 'claim', 'affiliate', 'discord']); const chosen = allowed.has(route) ? route : 'play'; user.miniAppLastSyncAt = Date.now(); - await ctx.reply(`Launching mini app route: ${chosen}\n${LINKS.miniAppPlay}?startapp=${chosen}`); + const launchLink = getPlayLink(user, chosen); + const modeLabel = user.settings && user.settings.playMode === 'browser' ? 'browser mode' : 'telegram mini app mode'; + await ctx.reply(`Launching ${modeLabel}: ${chosen}\n${launchLink}`); })); bot.command('claim_history', safeStepHandler('claim_history', async (ctx) => { @@ -6853,10 +6981,79 @@ bot.action('settings_toggle_tooltips', async (ctx) => { await sendSettingsMenu(ctx, user); }); + +bot.action('settings_group_linking_tools', async (ctx) => { + if (!requireAdmin(ctx)) { + await ctx.answerCbQuery('Admin only'); + return; + } + await ctx.answerCbQuery(); + await renderGroupLinkingTools(ctx, 'menu_settings_tab'); +}); + +bot.action('group_link_start', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + await ctx.answerCbQuery(); + user.pendingAction = { type: 'await_register_chat_forward', createdAt: Date.now() }; + await ctx.reply('Forward a message from the target group now to link it.'); +}); + +bot.action('group_link_view', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await renderGroupLinkingTools(ctx, 'menu_settings_tab'); +}); + +bot.action('group_link_remove_menu', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + const ids = Array.from(approvedGroupsStore).map((id) => Number(id)).filter((id) => Number.isFinite(id)); + if (!ids.length) { + await ctx.reply('No linked groups to remove.', Markup.inlineKeyboard([[Markup.button.callback('⬅️ Back', 'settings_group_linking_tools')]])); + return; + } + const rows = ids.map((id) => [Markup.button.callback(`❌ Remove ${id}`, `group_link_remove_${id}`)]); + rows.push([Markup.button.callback('⬅️ Back', 'settings_group_linking_tools')]); + await ctx.reply('Select a linked group to remove:', Markup.inlineKeyboard(rows)); +}); + +bot.action(/^group_link_remove_(-?\d+)$/, async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + const chatId = Number(ctx.match[1]); + approvedGroupsStore.delete(chatId); + persistRuntimeState(); + await ctx.reply(`Removed linked group ${chatId}.`, Markup.inlineKeyboard([[Markup.button.callback('🔗 Group Linking Tools', 'settings_group_linking_tools')]])); +}); + +bot.action('group_link_test_permissions', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + const ids = Array.from(approvedGroupsStore).map((id) => Number(id)).filter((id) => Number.isFinite(id)); + if (!ids.length) { + await ctx.reply('No linked groups configured to test.', Markup.inlineKeyboard([[Markup.button.callback('🔗 Group Linking Tools', 'settings_group_linking_tools')]])); + return; + } + const botInfo = await ctx.telegram.getMe(); + const lines = []; + for (const id of ids.slice(0, 10)) { + try { + const member = await ctx.telegram.getChatMember(id, botInfo.id); + const ok = ['administrator', 'creator'].includes(String(member.status || '').toLowerCase()); + lines.push(`${ok ? '✅' : '⚠️'} ${id}: ${member.status || 'unknown'}`); + } catch (e) { + lines.push(`❌ ${id}: ${e.message}`); + } + } + await ctx.reply(`Group permission test: +${lines.join('\n')}`, Markup.inlineKeyboard([[Markup.button.callback('🔗 Group Linking Tools', 'settings_group_linking_tools')]])); +}); + bot.action('menu_qc_play', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); - await ctx.reply('🎮 Open Runewager:', Markup.inlineKeyboard([[miniAppPlayButton()], [Markup.button.callback('⬅️ Main Menu', 'to_main_menu')]])); + await ctx.reply('🎮 Open Runewager:', Markup.inlineKeyboard([[miniAppPlayButton(user)], [Markup.button.callback('⬅️ Main Menu', 'to_main_menu')]])); }); bot.action('menu_qc_profile', async (ctx) => { @@ -7951,30 +8148,37 @@ Use *Start Giveaway* to override any value.`, ); }); -bot.action('admin_gw_group_linking', async (ctx) => { - if (!requireAdmin(ctx)) return; - await ctx.answerCbQuery(); - const groups = Array.from(approvedGroupsStore); - const lines = groups.length ? groups.map((id) => `• ${id}`).join('\n') : 'None linked yet.'; + + +function groupLinkingToolsKeyboard(returnCallback = 'menu_settings_tab') { + return Markup.inlineKeyboard([ + [Markup.button.callback('🔗 Link a Group', 'group_link_start')], + [Markup.button.callback('📄 View Linked Groups', 'group_link_view')], + [Markup.button.callback('🗑 Remove Linked Group', 'group_link_remove_menu')], + [Markup.button.callback('🧪 Test Group Permissions', 'group_link_test_permissions')], + [Markup.button.callback('⬅️ Back', returnCallback)], + [Markup.button.callback('❌ Cancel', 'to_main_menu')], + ]); +} + +async function renderGroupLinkingTools(ctx, returnCallback = 'menu_settings_tab') { + const groups = Array.from(approvedGroupsStore).map((id) => Number(id)).filter((id) => Number.isFinite(id)); + const lines = groups.length ? groups.map((id) => `• ${id}`).join('\n') : '• None linked yet.'; await ctx.reply( `🔗 *Group Linking Tools* -Linked groups for giveaways: +Linked groups: ${lines} -Use commands: -• /approve_group -• /unapprove_group -• /list_groups`, - { - parse_mode: 'Markdown', - ...Markup.inlineKeyboard([ - [Markup.button.callback('🧪 Run Test Giveaway', 'admin_cmd_testgiveaway')], - [Markup.button.callback('⬅️ Admin Dashboard', 'open_admin_dashboard')], - [Markup.button.callback('🏠 Main Menu', 'to_main_menu')], - ]), - }, +Choose an action below.`, + { parse_mode: 'Markdown', ...groupLinkingToolsKeyboard(returnCallback) }, ); +} + +bot.action('admin_gw_group_linking', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await renderGroupLinkingTools(ctx, 'admin_cat_giveaway'); }); bot.action('admin_gw_payout_manager', async (ctx) => { @@ -9187,13 +9391,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; @@ -9203,6 +9404,7 @@ bot.on('text', async (ctx) => { const cmd = text.slice(1).split(/\s+/)[0].split('@')[0].toLowerCase(); if (cmd === 'cancel') { user.pendingAction = null; + await clearOldMenus(ctx, user); await ctx.reply('✅ Pending action cancelled.', Markup.inlineKeyboard([[Markup.button.callback('🏠 Main Menu', 'to_main_menu')]])); return; } @@ -9237,7 +9439,7 @@ bot.on('text', async (ctx) => { { parse_mode: 'Markdown', ...Markup.inlineKeyboard([ - [Markup.button.url('🎮 Discord Support', process.env.RW_DISCORD_SUPPORT || 'https://discord.gg/runewagers')], + [Markup.button.url('🎮 Discord Support', LINKS.rwDiscordSupport)], [Markup.button.callback('🏠 Main Menu', 'to_main_menu')], ]), }, @@ -12168,6 +12370,8 @@ bot.command('testall', async (ctx) => { } const pass = (cat, label) => addResult(cat, true, label); const fail = (cat, label, detail) => addResult(cat, false, label, detail); + const warnings = []; + const warn = (cat, label, detail = '') => { warnings.push({ cat, label, detail }); addResult(cat, true, `${label} (warn)`); }; // ── 1. Links ───────────────────────────────────────────────────────────── for (const [key, url] of Object.entries(LINKS)) { @@ -12344,11 +12548,44 @@ bot.command('testall', async (ctx) => { else fail('Giveaway System', 'createGiveaway_shape', JSON.stringify({ id: gw.id, status: gw.status })); } catch (e) { fail('Giveaway System', 'createGiveaway_shape', e.message); } + + // ── 10. PendingAction / Navigation checks ──────────────────────────────── + try { + const checkUser = createDefaultUser({ id: -50, username: 'pending', first_name: 'Pending' }); + checkUser.pendingAction = { type: 'await_tip_add_text', createdAt: Date.now() - (15 * 60 * 1000) }; + const boundary = evaluatePendingActionTimeout(checkUser, Date.now()); + if (boundary.expired === false) pass('PendingAction Checks', 'boundary_15m_not_expired'); + else fail('PendingAction Checks', 'boundary_15m_not_expired', 'Boundary should not expire at exactly 15m'); + } catch (e) { fail('PendingAction Checks', 'boundary_15m_not_expired', e.message); } + + try { + const checkUser2 = createDefaultUser({ id: -51, username: 'pending2', first_name: 'Pending2' }); + checkUser2.pendingAction = { type: 'await_tip_add_text', createdAt: Date.now() - (15 * 60 * 1000) - 1 }; + const expired = evaluatePendingActionTimeout(checkUser2, Date.now()); + if (expired.expired && String(expired.expiredType || '').includes('tip')) pass('PendingAction Checks', 'labelled_timeout_expiry'); + else fail('PendingAction Checks', 'labelled_timeout_expiry', 'Expected labelled timeout and expiry'); + } catch (e) { fail('PendingAction Checks', 'labelled_timeout_expiry', e.message); } + + if (typeof clearOldMenus === 'function') pass('Navigation Checks', 'clearOldMenus_defined'); + else fail('Navigation Checks', 'clearOldMenus_defined', 'Missing clearOldMenus helper'); + + if (approvedGroupsStore && typeof approvedGroupsStore.size === 'number') pass('Database Checks', 'linked_groups_store_readable'); + else fail('Database Checks', 'linked_groups_store_readable', 'approvedGroupsStore unreadable'); + + if (!process.env.HTTPS_KEY_PATH || !process.env.HTTPS_CERT_PATH) warn('Environment Checks', 'https_paths_optional', 'HTTPS cert/key not set (HTTP mode expected).'); + else { + try { + if (fs.existsSync(process.env.HTTPS_KEY_PATH) && fs.existsSync(process.env.HTTPS_CERT_PATH)) pass('Environment Checks', 'https_paths_exist'); + else fail('Environment Checks', 'https_paths_exist', 'Configured HTTPS path missing'); + } catch (e) { fail('Environment Checks', 'https_paths_exist', e.message); } + } + // ── Build report ────────────────────────────────────────────────────────── const durationMs = Date.now() - startTs; const allResults = Object.values(cats).flat(); const totalPassed = allResults.filter((r) => r.ok).length; const totalFailed = allResults.filter((r) => !r.ok).length; + const totalWarnings = warnings.length; const totalCount = allResults.length; const catLines = Object.entries(cats).map(([cat, items]) => { @@ -12365,6 +12602,7 @@ bot.command('testall', async (ctx) => { `🧪 *TestAll Report*`, `Status: ${totalFailed === 0 ? 'PASSED ✅' : 'FAILED ❌'}`, `Duration: ${(durationMs / 1000).toFixed(1)}s | ${totalPassed}/${totalCount} checks passed`, + `Summary: TestAll complete — ${totalPassed} passed, ${totalWarnings} warnings, ${totalFailed} failures.`, '', ...catLines, '', @@ -12516,7 +12754,10 @@ async function startConfiguredTestGiveaway(ctx) { bot.command('testgiveaway', async (ctx) => { if (!requireAdmin(ctx)) return; - await ctx.reply( + const user = getUser(ctx); + await replyMenu( + ctx, + user, `⚠️ *THIS IS ONLY A TEST GIVEAWAY — NO REAL SC WILL BE SENT.*\n\nDo you want to start the test giveaway now?`, { parse_mode: 'Markdown', @@ -13167,7 +13408,7 @@ bot.command('discord_confirm', async (ctx) => { { parse_mode: 'Markdown', ...Markup.inlineKeyboard([ - [Markup.button.url('🎮 Join Discord', process.env.RW_DISCORD_JOIN || 'https://discord.gg/runewagers')], + [Markup.button.url('🎮 Join Discord', LINKS.rwDiscordJoin)], [Markup.button.callback('✅ I\'ve Verified — Sync Now', 'menu_verify_account')], [Markup.button.callback('🏠 Main Menu', 'to_main_menu')], ]), diff --git a/test/smoke.test.js b/test/smoke.test.js index 6daa561..69d015d 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(); @@ -364,3 +397,130 @@ 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(', ')}`); +}); + + +test('menu navigation uses clearOldMenus in critical flows', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + assert.ok(source.includes('async function clearOldMenus('), 'Expected clearOldMenus helper'); + assert.ok(source.includes('await replyMenu(ctx, user, lines'), 'Expected settings menu to use replyMenu/clearOldMenus'); + assert.ok(source.includes('await replyMenu('), 'Expected main menu to use replyMenu/clearOldMenus'); + assert.ok(source.includes("bot.command('cancel'" ) && source.includes('await clearOldMenus(ctx, user);'), 'Expected /cancel to clear last menu'); +}); + +test('group linking tools are exposed in Settings and include required actions', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + assert.ok(source.includes("settings_group_linking_tools"), 'Expected settings callback for group linking tools'); + for (const cb of ['group_link_start', 'group_link_view', 'group_link_remove_menu', 'group_link_test_permissions']) { + assert.ok(source.includes(cb), `Expected callback handler: ${cb}`); + } +}); + +test('testall emits required final summary line', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + assert.ok(source.includes('TestAll complete — ${totalPassed} passed, ${totalWarnings} warnings, ${totalFailed} failures.'), 'Expected TestAll final summary string'); +}); diff --git a/test/unit.test.js b/test/unit.test.js index 51e5c9c..76b68b0 100644 --- a/test/unit.test.js +++ b/test/unit.test.js @@ -174,6 +174,88 @@ 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 }; + } + + 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 = ACTION_LABELS[user.pendingAction.type] || 'current action'; + user.pendingAction = null; + return { hadPending: true, expired: true, expiredType }; +} + + + +function unwrapTelegramUrl(url) { + const raw = String(url || '').trim(); + try { + const parsed = new URL(raw); + if (parsed.hostname === 't.me' || parsed.hostname === 'telegram.me') { + return parsed.searchParams.get('url') || raw; + } + } catch (_) { + return raw; + } + return raw; +} + +function getDiscordLink(url) { + const candidate = unwrapTelegramUrl(url); + try { + const parsed = new URL(candidate); + const host = parsed.hostname.toLowerCase(); + if ((host === 'discord.com' || host === 'www.discord.com' || host === 'discord.gg' || host === 'www.discord.gg') && parsed.protocol === 'https:') { + return `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`; + } + } catch (_) { /* ignore */ } + return 'https://discord.gg/runewagers'; +} + +function getBrowserLink(route = 'play') { + const map = { + play: 'https://gamble-codez.com/runewager', + profile: 'https://gamble-codez.com/runewager/profile', + claim: 'https://gamble-codez.com/runewager/claim', + affiliate: 'https://gamble-codez.com/runewager/affiliate', + }; + return map[route] || map.play; +} + +function getStartAppLink(route = 'play') { + return `https://t.me/RuneWager_bot/Play?startapp=${route}`; +} + +function getPlayLink(user, route = 'play') { + const mode = user && user.settings ? user.settings.playMode : 'miniapp'; + return mode === 'browser' ? getBrowserLink(route) : getStartAppLink(route); +} + // --------------------------------------------------------------------------- // Bonus status state machine // --------------------------------------------------------------------------- @@ -328,6 +410,68 @@ 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 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, 'add tip 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'); +}); + + + +test('browser mode returns external gamble-codez link only', () => { + const user = { settings: { playMode: 'browser' } }; + const out = getPlayLink(user, 'play'); + assert.match(out, /^https:\/\/gamble-codez\.com\//); + assert.ok(!out.includes('startapp=')); + assert.ok(!out.includes('t.me/')); +}); + +test('telegram mode returns startapp mini-app link only', () => { + const user = { settings: { playMode: 'miniapp' } }; + const out = getPlayLink(user, 'profile'); + assert.match(out, /^https:\/\/t\.me\//); + assert.ok(out.includes('startapp=profile')); +}); + +test('discord links remain external and unwrap telegram wrappers', () => { + const wrapped = 'https://t.me/iv?url=https%3A%2F%2Fdiscord.gg%2Frunewagers'; + const out = getDiscordLink(wrapped); + assert.equal(out, 'https://discord.gg/runewagers'); + assert.match(out, /^https:\/\/(discord\.gg|discord\.com)\//); + assert.ok(!out.includes('t.me')); +}); + // --------------------------------------------------------------------------- // Promo claim limit check (pure logic) // ---------------------------------------------------------------------------