diff --git a/.env.example b/.env.example index c9faf03..d8604d2 100644 --- a/.env.example +++ b/.env.example @@ -1,35 +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_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_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 7cf1ebe..b2bc9d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,9 @@ You are the Runewager Bot Ops Commander. You manage the full lifecycle of the Ru - 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 diff --git a/RUNEWAGER_FUNCTIONALITY_MAP.md b/RUNEWAGER_FUNCTIONALITY_MAP.md index c9836f1..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`). @@ -185,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. diff --git a/index.js b/index.js index dfc01dc..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', @@ -1203,6 +1254,8 @@ function createDefaultUser(user) { mainMenuChatId: null, adminMenuMsgId: null, adminMenuChatId: null, + lastMenuMsgId: null, + lastMenuChatId: null, ephemeralBonusMsgId: null, ephemeralBonusChatId: null, referralCount: 0, @@ -2454,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); } /** @@ -2465,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 ────────────────────────────────────────── @@ -3125,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')], ]); @@ -3243,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')], @@ -3613,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 = [ @@ -3627,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) }); } /** @@ -3656,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), ); @@ -6142,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."); }); @@ -6438,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) => { @@ -6894,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) => { @@ -7992,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) => { @@ -9241,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; } @@ -9275,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')], ]), }, @@ -12206,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)) { @@ -12382,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]) => { @@ -12403,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, '', @@ -12554,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', @@ -13205,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 ca29648..69d015d 100644 --- a/test/smoke.test.js +++ b/test/smoke.test.js @@ -502,3 +502,25 @@ test('.env.example documents key runtime env vars used by core flows', () => { 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 405ae88..76b68b0 100644 --- a/test/unit.test.js +++ b/test/unit.test.js @@ -210,6 +210,52 @@ function evaluatePendingActionTimeout(user, now = Date.now()) { 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 // --------------------------------------------------------------------------- @@ -401,6 +447,31 @@ test('pending action timeout uses fallback type label when missing', () => { 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) // ---------------------------------------------------------------------------