From 3f312954011dcff5d2c74fd1751fb409735a4c18 Mon Sep 17 00:00:00 2001 From: GambleCodez Affiliates Date: Thu, 26 Feb 2026 18:04:45 -0600 Subject: [PATCH] Fix load_tooltips commit scope to .gitignore only --- .env.example | 23 +- CLAUDE.md | 15 + RUNEWAGER_FUNCTIONALITY_MAP.md | 28 +- index.js | 652 ++++++++++++++++++++++++++++----- load_tooltips.sh | 63 ++++ test/smoke.test.js | 364 +++++++++++++++--- test/unit.test.js | 264 +++++++++++++ 7 files changed, 1258 insertions(+), 151 deletions(-) create mode 100755 load_tooltips.sh diff --git a/.env.example b/.env.example index 570902f..62a187e 100644 --- a/.env.example +++ b/.env.example @@ -1,27 +1,32 @@ +ADMIN_IDS=YOUR_TELEGRAM_USER_ID AFFILIATE_SOURCE=GambleCodez ANNOUNCE_CHANNEL="-1002648883359" -DEVICE=vps -ADMIN_IDS=YOUR_TELEGRAM_USER_ID +# Set in BotFather/non-privacy mode so bot can read group messages. +BOT_PRIVACY_MODE="disabled" 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..f0826da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,18 @@ 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.` +- Referral onboarding rules must be enforced in code/tests: one-time code entry during onboarding only, no self-referral, no post-onboarding referral application, and dual 7-day boost assignment on valid referral. + ## Workflow Patterns 1. git pull origin main 2. Install dependencies as needed (npm install / pip install -r requirements.txt) @@ -159,3 +171,6 @@ Every coding session must begin by reading RUNEWAGER_FUNCTIONALITY_MAP.md and mu Every coding session must begin by reading RUNEWAGER_FUNCTIONALITY_MAP.md and must end with a full docstring verification pass. No work is complete until every function has a complete docstring and the map is fully updated. All AI agent instruction files in this repo must enforce this baseline workflow: (1) read and understand `RUNEWAGER_FUNCTIONALITY_MAP.md` before changing code, (2) update the map after any code change, (3) run a full verification pass after the map update, and (4) do not consider work complete until docstrings, the map, and agent instruction files are synchronized. + +- Added operational script `load_tooltips.sh` (root) to populate `/var/www/html/Runewager/data/tooltips.json` with 15 approved HTML tooltips; bot now loads this system file on restart when present. +- Added 30 SC manual-review menu hardening with explicit user/admin submenus and admin audit logging to `/var/www/html/Runewager/logs/bonus_admin.log`. diff --git a/RUNEWAGER_FUNCTIONALITY_MAP.md b/RUNEWAGER_FUNCTIONALITY_MAP.md index 2995f2b..44542bf 100644 --- a/RUNEWAGER_FUNCTIONALITY_MAP.md +++ b/RUNEWAGER_FUNCTIONALITY_MAP.md @@ -46,6 +46,8 @@ 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. +- `to_main_menu` clears transient menu cards via `clearOldMenus(...)` before rendering persistent menu headers. ## 4. User Menu Tree @@ -54,11 +56,15 @@ 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 +- Referral system: Main Menu exposes **👥 Refer a Friend** submenu (`Your Referral Code`, `Share Your Code`, `How Referral Boosts Work`, Back/Cancel). +- Play-link routing is deterministic: browser mode always returns external `https://gamble-codez.com/...`; telegram mode always returns `startapp` mini-app deep links. +- Persistent user menu play button is settings-aware (`Play Mode`) and uses browser label/url when browser mode is enabled. +- 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 +83,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 +126,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 @@ -134,6 +142,8 @@ The bot uses `user.pendingAction.type` as its input state machine. Key families: ## 9. Onboarding Flow (full step map) 1. `/start` intro + age confirmation gate. +2. After age confirmation, onboarding asks: **Were you referred by a friend?** (`Yes`/`No`). +3. If yes, user enters a referral code once; valid code applies 2× giveaway boost to both users for 7 days and stores `{ referral_code_owner, referred_user_id, boost_expiration_timestamp }`. 2. Account setup guidance (Runewager + Discord verification steps). 3. Username linking via `/link` or text detect + confirm callbacks. 4. Promo and bonus prompts depending on linked state and eligibility. @@ -184,6 +194,7 @@ Notes: ## 14. Permissions & Access Control - Admin identity is based on `ADMIN_IDS` env list. +- `.env.example` documents both `BOT_PRIVACY_MODE="disabled"` (BotFather non-privacy mode), 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 +219,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 +232,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. @@ -237,6 +251,8 @@ No universal pendingAction timeout/auto-cancel was detected for all user/admin i - Markdown escaping utilities for user/path/output interpolation. - Command error wrappers to reduce undefined behavior on invalid usage. +- Share-ready referral HTML template is generated via helper and opened with Telegram native share URL (`t.me/share/url`) while preserving external join link. + ## 20. Edge Cases & Special Conditions - Empty states (no giveaways/promos/bugs) return explicit panels. @@ -325,6 +341,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. @@ -336,3 +353,6 @@ Mandatory rules for any AI agent touching this repo: 1. **Before writing or modifying any code, the AI must always read and familiarize itself with `RUNEWAGER_FUNCTIONALITY_MAP.md`. This file is the authoritative source of truth for all bot functionality. The AI must keep this file updated with full, easy‑to‑understand descriptions whenever functionality is added, changed, or removed. Updating this file is a required step at the end of every coding session.** 2. **After generating or modifying any functionality, the AI must run a follow-up audit to ensure the `RUNEWAGER_FUNCTIONALITY_MAP.md` file is fully updated and accurate. No coding session is complete until the map is updated and verified.** + +- 2026-02-26: Added `load_tooltips.sh` to seed `/var/www/html/Runewager/data/tooltips.json` (15 UTF-8 HTML tooltips) and wired runtime tooltip loading to prefer that system JSON on restart. +- 2026-02-26: Added deterministic 30 SC user submenu (`How It Works`, `Check My Eligibility`, `Request My Bonus`, `Check Bonus Status`) and Admin submenu (`View Pending Requests`, `Approve Bonus`, `Deny Bonus`, `View User History`, `Reset Attempts`) with manual-review copy and admin action logging to `/var/www/html/Runewager/logs/bonus_admin.log`. diff --git a/index.js b/index.js index d4b7685..072cb5a 100644 --- a/index.js +++ b/index.js @@ -24,15 +24,89 @@ 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 getPlayMode(user = null) { + const mode = user && user.settings ? user.settings.playMode : user && user.playMode; + return mode === 'browser' ? 'browser' : 'miniapp'; +} + +function getBrowserLink(route = 'play') { + const map = { + play: 'https://runewager.com/r/GambleCodez', + profile: 'https://www.runewager.com/profile', + claim: 'https://www.runewager.com/affiliate', + affiliate: 'https://www.runewager.com/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') { + return getPlayMode(user) === 'browser' ? getBrowserLink(route) : getStartAppLink(route); +} + +function getPlayButton(user = null) { + if (getPlayMode(user) === 'browser') { + return { + text: '🔵 Play & Win', + url: 'https://runewager.com/r/GambleCodez', + }; + } + return { + text: '🔵 Play & Win', + web_app: { url: 'https://t.me/RuneWager_bot/Play' }, + }; +} + +function playButtonMarkup(user = null) { + const button = getPlayButton(user); + if (button.url) return Markup.button.url(button.text, button.url); + return Markup.button.webApp(button.text, button.web_app.url); +} + 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', @@ -54,6 +128,7 @@ const PROMO_REQUIREMENT = { const TELEGRAM_CHANNEL_ID = process.env.TELEGRAM_CHANNEL_ID || process.env.ANNOUNCE_CHANNEL || ''; const TELEGRAM_GROUP_ID = process.env.TELEGRAM_GROUP_ID || ''; const ANNOUNCE_CHANNEL = TELEGRAM_CHANNEL_ID; +const BOT_PRIVACY_MODE = String(process.env.BOT_PRIVACY_MODE || '').trim().toLowerCase(); // Group chat_id for automatic Content Drops const TIPS_GROUP = process.env.TIPS_GROUP || TELEGRAM_GROUP_ID || ''; @@ -140,6 +215,29 @@ const COPY = { const bot = new Telegraf(BOT_TOKEN); + +// Callback-query hygiene middleware: if a callback handler sends a new reply, +// remove the source callback card first so menus do not stack. +bot.use(async (ctx, next) => { + if (!ctx.callbackQuery) return next(); + let cleared = false; + const clearSource = async () => { + if (cleared) return; + try { await ctx.deleteMessage(); } catch (_) { /* stale/missing is fine */ } + cleared = true; + }; + + const wrapReply = (fn) => async (...args) => { + await clearSource(); + return fn(...args); + }; + + ctx.reply = wrapReply(ctx.reply.bind(ctx)); + if (ctx.replyWithPhoto) ctx.replyWithPhoto = wrapReply(ctx.replyWithPhoto.bind(ctx)); + if (ctx.replyWithAnimation) ctx.replyWithAnimation = wrapReply(ctx.replyWithAnimation.bind(ctx)); + return next(); +}); + // ========================= // Global Telegraf error handler // Catches any unhandled error thrown inside a bot.command() or bot.action() handler. @@ -224,6 +322,8 @@ const dashboardFile = path.join(dataDir, 'dashboard.json'); const runtimeStateFile = path.join(dataDir, 'runtime-state.json'); const promoManagerDbFile = path.join(dataDir, 'promo-manager-db.json'); const helpfulMessagesFile = path.join(dataDir, 'helpful_messages.json'); +const systemTooltipsFile = '/var/www/html/Runewager/data/tooltips.json'; +const bonusAdminLogFile = '/var/www/html/Runewager/logs/bonus_admin.log'; const sshvSessionsFile = path.join(dataDir, 'sshv-sessions.json'); @@ -312,7 +412,7 @@ function saveHelpfulMessages() { */ function loadHelpfulMessages() { - const raw = loadJson(helpfulMessagesFile, null); + const raw = loadJson(systemTooltipsFile, loadJson(helpfulMessagesFile, null)); if (!Array.isArray(raw)) return; const normalized = raw .map((entry, idx) => normalizeHelpfulMessageEntry(entry, idx + 1)) @@ -321,12 +421,21 @@ function loadHelpfulMessages() { tipsStore.tips = normalized; tipsStore.nextTipId = normalized.reduce((maxId, t) => Math.max(maxId, t.id), 0) + 1; } + +function appendBonusAdminLog(actorId, action, details = '') { + try { + fs.mkdirSync(path.dirname(bonusAdminLogFile), { recursive: true }); + const line = `[${new Date().toISOString()}] actor=${actorId || 'system'} action=${action}${details ? ` details=${details}` : ''} +`; + fs.appendFileSync(bonusAdminLogFile, line, 'utf8'); + } catch (_) { /* ignore log write errors */ } +} const backupDir = path.join(dataDir, 'backups'); const MAX_ANALYTICS_EVENTS = Number(process.env.MAX_ANALYTICS_EVENTS || 2000); const MAX_ONBOARDING_STEPS_HISTORY = Number(process.env.MAX_ONBOARDING_STEPS_HISTORY || 500); const promoHistoryStore = new Map(); const analyticsStore = { events: [], onboardingStarts: 0, onboardingCompletions: 0, webAppEvents: 0 }; -const referralStore = { boosts: [], archivedBoosts: [], links: new Map(), stats: new Map(), weekly: new Map() }; +const referralStore = { boosts: [], archivedBoosts: [], links: new Map(), stats: new Map(), weekly: new Map(), referrals: [] }; const approvedGroupsStore = new Set(); // Feature 8: set of chat_id numbers approved for giveaway posting const broadcastFailedUsers = []; // Feature 5: [{userId, lastError, failedAt}] permanently failed users const smartButtonTracker = new Map(); // token -> expiry timestamp (TTL = 2 minutes) @@ -380,6 +489,58 @@ 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', + await_referral_code: 'referral code entry', +}; + +/** + * 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 }; + } + + const nowMs = Number(now); + const safeNow = Number.isFinite(nowMs) ? nowMs : Date.now(); + const createdRaw = user.pendingAction.createdAt; + const createdMs = Number(createdRaw); + if (!Number.isFinite(createdMs)) user.pendingAction.createdAt = safeNow; + + const normalizedCreatedAt = Number(user.pendingAction.createdAt); + if (!Number.isFinite(normalizedCreatedAt)) { + user.pendingAction.createdAt = safeNow; + return { hadPending: true, expired: false, expiredType: null }; + } + + if ((safeNow - normalizedCreatedAt) <= 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', @@ -738,6 +899,7 @@ function createRuntimeStateSnapshot() { links: Array.from(referralStore.links.entries()), stats: Array.from(referralStore.stats.entries()), weekly: Array.from(referralStore.weekly.entries()), + referrals: referralStore.referrals || [], }, approvedGroupsStore: Array.from(approvedGroupsStore), broadcastFailedUsers, @@ -963,6 +1125,7 @@ function loadRuntimeState() { referralStore.links = new Map(raw.referralStore.links || []); referralStore.stats = new Map(raw.referralStore.stats || []); referralStore.weekly = new Map(raw.referralStore.weekly || []); + referralStore.referrals = Array.isArray(raw.referralStore.referrals) ? raw.referralStore.referrals : []; } if (Array.isArray(raw.approvedGroupsStore)) { for (const id of raw.approvedGroupsStore) approvedGroupsStore.add(Number(id)); @@ -1162,11 +1325,15 @@ function createDefaultUser(user) { mainMenuChatId: null, adminMenuMsgId: null, adminMenuChatId: null, + lastMenuMsgId: null, + lastMenuChatId: null, ephemeralBonusMsgId: null, ephemeralBonusChatId: null, referralCount: 0, lastReferralAt: 0, boostExpiresAt: 0, // ms timestamp; if > now, winner gets 2× SC + referredByUserId: 0, + referralAppliedAt: 0, commandsUsed: 0, sessionsCount: 0, wagerBonus: { @@ -2256,14 +2423,7 @@ function checkBonusEligibility(user) { if (user.wagerBonus.attempts >= 3) { return { ok: false, reason: 'You have reached the maximum of 3 requests.' }; } - // 4. Wager check — ask for declaration only when missing - const declaredWager = Number(user.wagerBonus.wager7dayTotal); - if (!Number.isFinite(declaredWager) || declaredWager <= 0) { - return { ok: false, needsWager: true, reason: 'Please declare your 7-day wager total first.' }; - } - if (declaredWager < 3000) { - return { ok: false, reason: `Your declared 7-day wager (${declaredWager} SC) is below the required 3,000 SC.` }; - } + // 4. Manual-only review policy (no API checks, no auto wager checks) return { ok: true }; } @@ -2282,25 +2442,12 @@ async function submitBonusRequest(ctx, user) { const wb = user.wagerBonus; const attemptsLeft = 3 - wb.attempts; await ctx.reply( - `✅ Your request has been submitted!\n\n` - + `${AFFILIATE_REMINDER_TEXT}\n\n` - + `• You must wager 3,000 SC in any rolling 7-day period\n` - + `• Bonus is once per account\n` - + `• Attempt ${wb.attempts}/3 used (${attemptsLeft} remaining)\n\n` - + `Admin has been notified and will manually review and credit your 30 SC bonus on Runewager.`, - { parse_mode: 'Markdown' }, + 'Your request has been submitted to GambleCodez for manual review. Please allow 24-48 hours.\n\n' + + `Attempts used: ${wb.attempts}/3 (${attemptsLeft} remaining).`, ); - const handle = user.tgUsername ? `@${user.tgUsername}` : `user${user.id}`; - await notifyAdminsPlain( - `🎯 *New 30 SC bonus request — admin action required*\n` - + `From: ${handle} (TG ID: ${user.id})\n` - + `Runewager: ${user.runewagerUsername || '(not linked)'}\n` - + `7-day wager declared: ${wb.wager7dayTotal} SC\n` - + `Attempts: ${wb.attempts}/3\n` - + `Status: pending\n` - + `\nReview with: /bonus pending`, - ); + appendBonusAdminLog(user.id, 'bonus_request_submitted', `attempt=${wb.attempts}`); + await notifyAdminsPlain(`User ${user.id} requested 30 SC bonus. Manual review required.`); } /** @@ -2324,6 +2471,8 @@ async function showBonusStatus(ctx, user) { '', `Attempts used: ${wb.attempts}/3 (${attemptsLeft} remaining)`, `Status: ${statusLabels[wb.status] || wb.status}`, + wb.approvedAt ? `Approved at: ${new Date(wb.approvedAt).toISOString()}` : '', + wb.updatedAt ? `Last review update: ${new Date(wb.updatedAt).toISOString()}` : '', `Runewager username: ${user.runewagerUsername || '(not linked)'}`, `Affiliate: ${wb.affiliateSource || 'GambleCodez'}`, `Wager on file: ${wagerDisplay}`, @@ -2413,8 +2562,8 @@ function promoButton() { */ -function miniAppPlayButton() { - return Markup.button.url('🎮 Open Runewager (Mini App)', LINKS.miniAppPlay); +function miniAppPlayButton(user = null) { + return playButtonMarkup(user); } /** @@ -2424,10 +2573,6 @@ 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; - if (page === 2) { // ── Page 2 — Community & Extras ────────────────────────────────────────── const rows = [ @@ -2444,7 +2589,7 @@ function mainMenuKeyboard(isAdminUser = false, page = 1, user = null) { Markup.button.url('💬 Discord', LINKS.rwDiscordJoin), ], [ - Markup.button.callback('🏆 Referral Link', 'menu_referral'), + Markup.button.callback('👥 Refer a Friend', 'menu_referral'), Markup.button.callback('👤 My Profile', 'menu_profile_action'), ], [ @@ -2476,7 +2621,7 @@ function mainMenuKeyboard(isAdminUser = false, page = 1, user = null) { // ── Page 1 — Primary Actions ─────────────────────────────────────────────── const rows = [ // Primary CTA — full width - [Markup.button.url('🎮 Play Runewager', playUrl)], + [playButtonMarkup(user)], // Account category [ Markup.button.callback('✅ Create / Verify Account', 'menu_verify_account'), @@ -2502,7 +2647,7 @@ function mainMenuKeyboard(isAdminUser = false, page = 1, user = null) { if (user && user.settings && user.settings.showQuickCommands) { rows.push([ Markup.button.callback('▶ Giveaways', 'menu_giveaways'), - Markup.button.callback('▶ Referral', 'menu_referral'), + Markup.button.callback('👥 Refer a Friend', 'menu_referral'), Markup.button.callback('▶ Status', 'menu_qc_status'), Markup.button.callback('▶ Help', 'menu_help'), ]); @@ -3084,6 +3229,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')], ]); @@ -3152,6 +3298,7 @@ function adminDashboardKeyboard(page = 1) { ], [ Markup.button.callback('📈 Stats', 'admin_stats_menu'), + Markup.button.callback('30 SC Bonus Admin', 'w30_admin_menu'), Markup.button.callback('▶️ Quick Actions', 'admin_dash_page_2'), ], [Markup.button.callback('⬅️ Back to User Menu', 'to_main_menu')], @@ -3202,7 +3349,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 +3718,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 +3761,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 +3790,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), ); @@ -3624,9 +3801,9 @@ async function sendMainMenu(ctx, user, page = 1) { // ── Persistent Menu System ────────────────────────────────────────────────── /** Keyboard for the new persistent USER MAIN MENU */ -function userMainMenuKeyboard(isAdminUser) { +function userMainMenuKeyboard(isAdminUser, user = null) { const rows = [ - [Markup.button.url('🎮 Play Runewager', LINKS.miniAppPlay)], + [playButtonMarkup(user)], [ Markup.button.callback('🎁 Bonuses & Rewards', 'pmenu_claim_bonus'), Markup.button.callback('👤 Profile & Link', 'pmenu_my_profile'), @@ -3651,7 +3828,7 @@ function userMainMenuText(user) { `👋 Hey ${name}! Here's your Runewager menu.\n\n` + (statusLine ? `${statusLine}\n\n` : '') + `🌍 *Runewager is 100% FREE to play — worldwide access, no deposits.*\n\n` - + `🎮 *Play Runewager* — Open the Runewager Mini App\n` + + `🎮 *Play Runewager* — Opens Browser or Mini App based on your Play Mode setting\n` + `🎁 *Bonuses & Rewards* — Promo menu + 30 SC wager bonus\n` + `👤 *Profile & Link* — Linked account, XP, badges & referrals\n` + `🏆 *Giveaways & Community* — Active SC giveaways + join links\n` @@ -3668,6 +3845,7 @@ function userMainMenuText(user) { async function sendPersistentUserMenu(ctx, user) { const chatId = ctx.chat ? ctx.chat.id : (ctx.callbackQuery && ctx.callbackQuery.message ? ctx.callbackQuery.message.chat.id : null); if (!chatId) return; + await clearOldMenus(ctx, user); await deleteEphemeralBonusPrompt(ctx, user); // Show admin button only when admin view is ON const isAdminUser = ADMIN_IDS.includes(Number(user.id)) && user.adminModeOn; @@ -3691,7 +3869,7 @@ async function sendPersistentUserMenu(ctx, user) { } const text = userMainMenuText(user); - const keyboard = userMainMenuKeyboard(isAdminUser); + const keyboard = userMainMenuKeyboard(isAdminUser, user); const sent = await ctx.telegram.sendMessage(chatId, text, { parse_mode: 'Markdown', ...keyboard }); user.mainMenuMsgId = sent.message_id; user.mainMenuChatId = sent.chat.id; @@ -4477,6 +4655,48 @@ function referralCodeForUser(user) { return referralStore.links.get(user.id); } + +function referralShareHTML(code) { + return `🚨 YO! I’m farming free SC on Runewager and you need to get in NOW. + +Daily giveaways, instant rewards, drops, and a secret new-user bonus waiting inside the bot. + +Use my code ${escapeHtml(code)} when you start — we BOTH get a 2× boost on all giveaways for 7 days. + +👉 Tap here to join Runewager + +Don’t sleep. This thing prints. ⚡👑`; +} + +function applyOnboardingReferralCode(user, referralCode) { + const code = String(referralCode || '').trim().toLowerCase(); + if (!code) return { ok: false, reason: 'Please enter a referral code.' }; + if (!user.ageConfirmed) return { ok: false, reason: 'Referral codes can only be entered during onboarding.' }; + if ((user.referralAppliedAt || 0) > 0 || (user.referredByUserId || 0) > 0) return { ok: false, reason: 'Referral code already used on this account.' }; + if (user.onboarding && user.onboarding.completedAt) return { ok: false, reason: 'Referral code can only be entered before onboarding is complete.' }; + + let owner = null; + for (const [, u] of userStore) { + if (String(referralCodeForUser(u) || '').toLowerCase() === code) { owner = u; break; } + } + if (!owner) return { ok: false, reason: 'Referral code not found.' }; + if (Number(owner.id) === Number(user.id)) return { ok: false, reason: 'You cannot use your own referral code.' }; + + const now = Date.now(); + const expiresAt = now + 7 * 24 * 60 * 60 * 1000; + user.referredByUserId = owner.id; + user.referralTag = code; + user.referralAppliedAt = now; + user.boostExpiresAt = Math.max(Number(user.boostExpiresAt || 0), expiresAt); + owner.boostExpiresAt = Math.max(Number(owner.boostExpiresAt || 0), expiresAt); + owner.referralCount = (owner.referralCount || 0) + 1; + owner.lastReferralAt = now; + referralStore.referrals.push({ referral_code_owner: owner.id, referred_user_id: user.id, boost_expiration_timestamp: expiresAt, created_at: now }); + awardReferralProgress(owner.id, 1); + persistRuntimeState(); + return { ok: true, ownerId: owner.id, expiresAt }; +} + /** * getActiveReferralBoost executes its scoped Runewager logic and participates in menu/command or utility flow composition. @@ -5183,6 +5403,7 @@ bot.command('menu', async (ctx) => { const user = getUser(ctx); clearPendingAction(user); if (!user.ageConfirmed) { + await clearOldMenus(ctx, user); await ctx.reply(COPY.ageGate, { parse_mode: 'Markdown', ...ageGateKeyboard() }); return; } @@ -5192,6 +5413,7 @@ bot.command('menu', async (ctx) => { bot.command('help', async (ctx) => { const user = getUser(ctx); clearPendingAction(user); + await clearOldMenus(ctx, user); await sendHelpMenu(ctx, user, 1); }); @@ -5199,12 +5421,14 @@ bot.command('help', async (ctx) => { bot.command('commands', async (ctx) => { const user = getUser(ctx); clearPendingAction(user); + await clearOldMenus(ctx, user); await sendHelpMenu(ctx, user, 1); }); bot.command('settings', async (ctx) => { const user = getUser(ctx); clearPendingAction(user); + await clearOldMenus(ctx, user); await sendSettingsMenu(ctx, user); }); @@ -5708,6 +5932,7 @@ bot.command('admin', async (ctx) => { return; } const user = getUser(ctx); + await clearOldMenus(ctx, user); persistAdminMode(user, true); await refreshAdminMenuHeader(ctx, user); const isGroup = ctx.chat && (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'); @@ -5726,6 +5951,7 @@ bot.command('admin', async (ctx) => { bot.command('sshv', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); + await clearOldMenus(ctx, user); persistAdminMode(user, true); await refreshAdminMenuHeader(ctx, user); const session = getSshvSession(user.id, { createIfMissing: true }); @@ -6101,6 +6327,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 +6624,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) => { @@ -6518,6 +6747,7 @@ bot.action('ref_leaderboard', async (ctx) => { bot.command('on', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); + await clearOldMenus(ctx, user); persistAdminMode(user, true); await refreshAdminMenuHeader(ctx, user); await ctx.reply('🔧 Admin Mode Enabled'); @@ -6655,6 +6885,7 @@ bot.command('join', async (ctx) => { bot.action('to_main_menu', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); + await clearOldMenus(ctx, user); await deleteEphemeralBonusPrompt(ctx, user); await sendPersistentUserMenu(ctx, user); }); @@ -6683,7 +6914,7 @@ bot.action('pmenu_claim_bonus', async (ctx) => { await replaceCallbackPanel(ctx, '🎁 *Bonus Options*\n\n' + '• *New User Bonus* — Claim signup promo code (once per account)\n' - + '• *30 SC Wager Bonus* — Submit your wager proof for 30 SC', + + '• *30 SC Bonus* — Manual review request flow', { parse_mode: 'Markdown', ...Markup.inlineKeyboard([ @@ -6836,6 +7067,8 @@ bot.action('settings_toggle_playmode', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); user.settings.playMode = user.settings.playMode === 'browser' ? 'miniapp' : 'browser'; + persistRuntimeState(); + await clearOldMenus(ctx, user); await sendSettingsMenu(ctx, user); }); @@ -6853,10 +7086,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([[playButtonMarkup(user)], [Markup.button.callback('⬅️ Main Menu', 'to_main_menu')]])); }); bot.action('menu_qc_profile', async (ctx) => { @@ -6873,7 +7175,49 @@ bot.action('menu_qc_status', async (ctx) => { bot.action('w30_request_start', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); - await showBonusStatus(ctx, user); + await ctx.reply( + '🎁 *30 SC Bonus*\n\nChoose an option:', + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('How It Works', 'w30_menu_how')], + [Markup.button.callback('Check My Eligibility', 'w30_menu_eligibility')], + [Markup.button.callback('Request My Bonus', 'w30_menu_request')], + [Markup.button.callback('Check Bonus Status', 'w30_my_status')], + [Markup.button.callback('⬅️ Back', 'to_main_menu'), Markup.button.callback('❌ Cancel', 'to_main_menu')], + ]), + }, + ); +}); + +bot.action('w30_menu_how', async (ctx) => { + await ctx.answerCbQuery(); + await ctx.reply( + 'You can request a one-time 30 SC bonus after wagering 3,000 SC in any rolling 7-day period. ' + + 'When you believe you\'ve met the requirement, tap Request My Bonus. ' + + 'The bot will forward your request to GambleCodez for manual review. No screenshots needed. ' + + 'Reviews take 24–48 hours. Once per account/IP.', + Markup.inlineKeyboard([[Markup.button.callback('⬅️ Back', 'w30_request_start')], [Markup.button.callback('❌ Cancel', 'to_main_menu')]]), + ); +}); + +bot.action('w30_menu_eligibility', async (ctx) => { + await ctx.answerCbQuery(); + await ctx.reply( + 'Eligibility is reviewed manually by GambleCodez.\n\n• You may request once per account/IP.\n• Admin review takes 24–48 hours.', + Markup.inlineKeyboard([[Markup.button.callback('⬅️ Back', 'w30_request_start')], [Markup.button.callback('❌ Cancel', 'to_main_menu')]]), + ); +}); + +bot.action('w30_menu_request', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + const check = checkBonusEligibility(user); + if (!check.ok) { + await ctx.reply(check.reason, Markup.inlineKeyboard([[Markup.button.callback('⬅️ Back', 'w30_request_start')]])); + return; + } + await submitBonusRequest(ctx, user); }); bot.action('w30_bonus_info', async (ctx) => { @@ -7217,7 +7561,34 @@ bot.action('age_yes', async (ctx) => { trackOnboardingProgress(user); await ctx.answerCbQuery('Age confirmed.'); await reactToMessage(ctx, '👍'); - // Show GambleCodez VIP onboarding step before main menu + await sendOnboardingReferralPrompt(ctx, user); +}); + + +async function sendOnboardingReferralPrompt(ctx, user) { + if ((user.referralAppliedAt || 0) > 0 || (user.referredByUserId || 0) > 0) { + await sendGambleCodezVIPStep(ctx, user); + return; + } + await ctx.reply( + 'Were you referred by a friend?', + Markup.inlineKeyboard([ + [Markup.button.callback('✅ Yes, I was referred', 'onboard_ref_yes')], + [Markup.button.callback('➡️ No, continue', 'onboard_ref_no')], + ]), + ); +} + +bot.action('onboard_ref_yes', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + user.pendingAction = { type: 'await_referral_code', createdAt: Date.now() }; + await ctx.reply('Please enter your referral code now.'); +}); + +bot.action('onboard_ref_no', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); await sendGambleCodezVIPStep(ctx, user); }); @@ -7309,6 +7680,7 @@ bot.action('menu_verify_account', async (ctx) => { const user = getUser(ctx); clearPendingAction(user); if (!user.ageConfirmed) { + await clearOldMenus(ctx, user); await ctx.reply(COPY.ageGate, { parse_mode: 'Markdown', ...ageGateKeyboard() }); return; } @@ -7516,6 +7888,7 @@ bot.action('menu_claim_bonus', async (ctx) => { clearPendingAction(user); await ctx.answerCbQuery(); if (!user.ageConfirmed) { + await clearOldMenus(ctx, user); await ctx.reply(COPY.ageGate, { parse_mode: 'Markdown', ...ageGateKeyboard() }); return; } @@ -7637,14 +8010,39 @@ bot.command('pmdeny', safeAdminHandler('pmdeny', { usage: '/pmdeny { const user = getUser(ctx); await ctx.answerCbQuery(); - const code = referralCodeForUser(user); - const link = `https://t.me/${ctx.botInfo.username}?start=ref_${code}`; - await ctx.reply(`🏆 *Your Referral Link*\n\n${link}\n\nShare this link to earn referral rewards!`, { + await ctx.reply('👥 *Refer a Friend*\n\nChoose an option:', { parse_mode: 'Markdown', - ...Markup.inlineKeyboard([[Markup.button.callback('⬅️ Menu', 'to_main_menu')]]), + ...Markup.inlineKeyboard([ + [Markup.button.callback('🧾 Your Referral Code', 'ref_menu_code')], + [Markup.button.callback('📤 Share Your Code', 'ref_menu_share')], + [Markup.button.callback('ℹ️ How Referral Boosts Work', 'ref_menu_how')], + [Markup.button.callback('⬅️ Back', 'to_main_menu')], + [Markup.button.callback('❌ Cancel', 'to_main_menu')], + ]), }); }); +bot.action('ref_menu_code', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + const code = referralCodeForUser(user); + await ctx.reply(`Your referral code: \`${code}\``, { parse_mode: 'Markdown' }); +}); + +bot.action('ref_menu_how', async (ctx) => { + await ctx.answerCbQuery(); + await ctx.reply('Referral boosts: both you and your referred friend get 2× giveaway boost for 7 days when a valid referral code is entered during onboarding.'); +}); + +bot.action('ref_menu_share', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + const code = referralCodeForUser(user); + const html = referralShareHTML(code); + const shareUrl = `https://t.me/share/url?url=${encodeURIComponent('https://t.me/RunewagerBot')}&text=${encodeURIComponent(html)}`; + await ctx.reply('Tap below to open Telegram share sheet:', Markup.inlineKeyboard([[Markup.button.url('📤 Share Your Code', shareUrl)], [Markup.button.callback('⬅️ Back', 'menu_referral')]])); +}); + bot.action('menu_bugreport', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); @@ -7951,30 +8349,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) => { @@ -8073,6 +8478,7 @@ bot.action('admin_cmd_mode_toggle', async (ctx) => { bot.action('admin_cmd_mode_on', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); + await clearOldMenus(ctx, user); persistAdminMode(user, true); await ctx.answerCbQuery('Admin mode enabled'); await refreshAdminMenuHeader(ctx, user); @@ -8130,6 +8536,7 @@ bot.action('sshv_run_prompt', async (ctx) => { bot.action('sshv_open', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); + await clearOldMenus(ctx, user); persistAdminMode(user, true); const session = getSshvSession(user.id, { createIfMissing: true }); validateAndFixSshvSession(user, session, { onStartup: false }); @@ -8401,6 +8808,7 @@ bot.action('menu_admin_tab', async (ctx) => { bot.action('admin_auth_bypass', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); + await clearOldMenus(ctx, user); persistAdminMode(user, true); await refreshAdminMenuHeader(ctx, user); await ctx.answerCbQuery('Admin mode enabled.'); @@ -8650,6 +9058,25 @@ bot.action('admin_broadcast_yes', async (ctx) => { // --- Inline keyboard button handlers --- +bot.action('w30_admin_menu', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await ctx.reply( + '🎁 *30 SC Bonus Admin*\n\nManual review tools:', + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('View Pending Requests', 'w30_admin_pending')], + [Markup.button.callback('Approve Bonus', 'w30_admin_approve_pick')], + [Markup.button.callback('Deny Bonus', 'w30_admin_deny_pick')], + [Markup.button.callback('View User History', 'w30_admin_lookup')], + [Markup.button.callback('Reset Attempts', 'w30_admin_reset')], + [Markup.button.callback('⬅️ Back', 'open_admin_dashboard'), Markup.button.callback('❌ Cancel', 'to_main_menu')], + ]), + }, + ); +}); + bot.action('w30_admin_pending', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); @@ -9187,13 +9614,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 +9627,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 +9662,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')], ]), }, @@ -9421,6 +9846,19 @@ Content Drops and group broadcasts will now use this target.`); } // User linking flow — NEVER auto-accept; always require explicit confirmation + + if (action.type === 'await_referral_code') { + const result = applyOnboardingReferralCode(user, text); + if (!result.ok) { + await ctx.reply(`❌ ${result.reason}`, Markup.inlineKeyboard([[Markup.button.callback('❌ Skip Referral', 'onboard_ref_no')]])); + return; + } + user.pendingAction = null; + await ctx.reply('✅ Referral applied! Both accounts now have a 2× giveaway boost for 7 days.'); + await sendGambleCodezVIPStep(ctx, user); + return; + } + if (action.type === 'await_runewager_username') { if (!text) { await ctx.reply( @@ -11975,12 +12413,14 @@ async function bonusAdminApprove(ctx, rawId) { const current = target.wagerBonus.status; if (!isValidBonusTransition(current, 'approved')) throw new Error(`Invalid bonus transition: ${current} -> approved`); target.wagerBonus.status = 'approved'; + target.wagerBonus.approvedAt = Date.now(); target.wagerBonus.updatedAt = Date.now(); }); const approvedName = target.tgUsername || `user${target.id}`; + appendBonusAdminLog(ctx.from && ctx.from.id, 'bonus_approved', `target=${target.id}`); await ctx.reply(`✅ Approved. TG ID: ${target.id} (@${approvedName})\n\nNow manually credit the 30 SC on Runewager, then run:\n/bonus sent ${target.id}`); try { - await bot.telegram.sendMessage(target.id, 'Your 30 SC bonus request was approved. An admin will manually credit your bonus on Runewager shortly.'); + await bot.telegram.sendMessage(target.id, 'Your 30 SC bonus has been approved and will be added by GambleCodez.'); } catch (e) { /* ignore */ } } @@ -12016,9 +12456,10 @@ async function bonusAdminDeny(ctx, rawId, reason) { target.wagerBonus.updatedAt = Date.now(); }); const deniedName = target.tgUsername || `user${target.id}`; + appendBonusAdminLog(ctx.from && ctx.from.id, 'bonus_denied', `target=${target.id};reason=${target.wagerBonus.denyReason}`); await ctx.reply(`❌ Denied. TG ID: ${target.id} (@${deniedName})\nReason: ${target.wagerBonus.denyReason}`); try { - await bot.telegram.sendMessage(target.id, `Your 30 SC bonus request was denied.\nReason: ${target.wagerBonus.denyReason}`); + await bot.telegram.sendMessage(target.id, `Your 30 SC bonus request was denied: ${target.wagerBonus.denyReason}`); } catch (e) { /* ignore */ } } @@ -12168,6 +12609,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)) { @@ -12206,6 +12649,8 @@ bot.command('testall', async (ctx) => { else fail('Configuration', 'ADMIN_IDS', 'No admin IDs configured'); if (BOT_TOKEN && BOT_TOKEN.length > 10) pass('Configuration', 'BOT_TOKEN'); else fail('Configuration', 'BOT_TOKEN', 'Missing or too short'); + if (BOT_PRIVACY_MODE === 'disabled') pass('Environment Checks', 'BOT_PRIVACY_MODE=disabled'); + else fail('Environment Checks', 'BOT_PRIVACY_MODE=disabled', `Expected "disabled" for group visibility, got: ${BOT_PRIVACY_MODE || 'unset'}`); for (const [key, expected] of [['miniAppPlay', 't.me/RuneWager_bot/Play'], ['miniAppProfile', 't.me/RuneWager_bot/profile'], ['miniAppClaim', 't.me/RuneWager_bot/claim']]) { if (LINKS[key] && LINKS[key].includes(expected)) pass('Configuration', `miniApp_link.${key}`); else fail('Configuration', `miniApp_link.${key}`, `Expected ${expected}, got: ${LINKS[key]}`); @@ -12344,11 +12789,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 +12843,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 +12995,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', @@ -13043,7 +13525,7 @@ bot.command('list_groups', safeAdminHandler('list_groups', { usage: '/list_group ` Bot admin: ${isAdmin ? 'YES' : 'NO'}`, ` Missing permissions: ${missing.length ? missing.join(', ') : 'none'}`, ' Recommended BotFather: disable privacy mode if group message visibility is required.', - ` Privacy mode: ${process.env.BOT_PRIVACY_MODE || 'unknown (check @BotFather)'}`, + ` Privacy mode: ${BOT_PRIVACY_MODE || 'unknown (check @BotFather)'}`, ].join('\n')); } catch (e) { report.push(`\n• ${chatId}\n Error: ${e.message}`); @@ -13167,7 +13649,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/load_tooltips.sh b/load_tooltips.sh new file mode 100755 index 0000000..a7ec461 --- /dev/null +++ b/load_tooltips.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +set -euo pipefail + +echo "=== GCZ — TOOLTIP PIPELINE EXECUTION ===" + +cd /var/www/html/Runewager + +echo "[1] Pulling latest from origin main..." +git pull origin main + +echo "[2] Ensuring data directory exists..." +mkdir -p /var/www/html/Runewager/data + +echo "[3] Ensuring tooltips.json exists..." +if [ ! -f /var/www/html/Runewager/data/tooltips.json ]; then + echo "[]" > /var/www/html/Runewager/data/tooltips.json + echo "Created empty tooltips.json" +fi + +echo "[6] Adding data/tooltips.json to .gitignore if missing..." +grep -qxF "data/tooltips.json" .gitignore || echo "data/tooltips.json" >> .gitignore + +echo "[7] Staging .gitignore only..." +git add .gitignore + +if git diff --cached --quiet -- .gitignore; then + echo "[8] No changes to commit; skipping git commit/push." +else + echo "[8] Committing..." + git commit -m "GCZ: ensure tooltips.json exists, ignore it, and run load_tooltips.sh" -- .gitignore + + echo "[9] Pushing to origin main..." + git push origin main +fi + +echo "=== GCZ — DONE ===" + +OUT="/var/www/html/Runewager/data/tooltips.json" +mkdir -p "$(dirname "$OUT")" + +cat > "$OUT" <<'JSON' +[ + {"id":1,"text":"Welcome to Runewager — everything here is free to play with prize redemptions.","enabled":true}, + {"id":2,"text":"Tap 🔵 Play & Win to launch using your current Play Mode setting.","enabled":true}, + {"id":3,"text":"Use Settings to switch between Browser mode and Mini App mode anytime.","enabled":true}, + {"id":4,"text":"Link your username early so bonuses and giveaways can be tracked correctly.","enabled":true}, + {"id":5,"text":"The 30 SC Bonus is reviewed manually by GambleCodez — no screenshots required.","enabled":true}, + {"id":6,"text":"Need help? Open Help / Commands from the main menu.","enabled":true}, + {"id":7,"text":"Join community hubs for updates: Channel and Group.","enabled":true}, + {"id":8,"text":"Referral boosts can grant a 2× giveaway boost for 7 days.","enabled":true}, + {"id":9,"text":"All Discord actions open externally; this bot does not use Discord APIs.","enabled":true}, + {"id":10,"text":"Use /menu if you need to reset navigation and reopen the persistent menu.","enabled":true}, + {"id":11,"text":"Admins can use /testall to run full diagnostics and health checks.","enabled":true}, + {"id":12,"text":"Group Linking Tools are in Settings for linking, viewing, removing, and permission tests.","enabled":true}, + {"id":13,"text":"Bonus requests are manual and limited by policy; keep your account details accurate.","enabled":true}, + {"id":14,"text":"Use Cancel or Back buttons to safely exit any pending flow.","enabled":true}, + {"id":15,"text":"Runewager remains 100% free to play with worldwide access.","enabled":true} +] +JSON + +python3 -m json.tool "$OUT" >/dev/null +echo "✅ Tooltips loaded successfully into $OUT" diff --git a/test/smoke.test.js b/test/smoke.test.js index 6daa561..4e73ff2 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,53 @@ 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) { + let compact = String(patternSource || '').replace(/\s+/g, ''); + compact = compact.replace(/^\^/, '').replace(/\$$/, ''); + while (compact.startsWith('(') && compact.endsWith(')')) compact = compact.slice(1, -1); + compact = compact.replace(/^\?:/, ''); + + if (['.*', '.+', '.*?', '.+?'].includes(compact)) return true; + if (/^\(\?:?\.?\*\)$/.test(String(patternSource || '').replace(/\s+/g, ''))) return true; + return false; +} + +/** + * 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 +394,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 +403,222 @@ 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'); +}); + + +test('referral menu includes required friend-invite actions', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + for (const cb of ['menu_referral', 'ref_menu_code', 'ref_menu_share', 'ref_menu_how']) { + assert.ok(source.includes(cb), `Expected referral callback: ${cb}`); + } + assert.ok(source.includes('Were you referred by a friend?'), 'Expected onboarding referral prompt text'); + assert.ok(source.includes('await_referral_code'), 'Expected referral onboarding pending action'); +}); + +test('testall covers required diagnostic category labels', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + for (const cat of ['Environment Checks', 'Database Checks', 'Navigation Checks', 'PendingAction Checks']) { + assert.ok(source.includes(cat), `Expected TestAll category/check mention: ${cat}`); + } +}); + + +test('to_main_menu clears old menus before rendering persistent menu', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + const blockStart = source.indexOf("bot.action('to_main_menu'"); + assert.ok(blockStart >= 0, 'Expected to_main_menu callback'); + const block = source.slice(blockStart, blockStart + 400); + assert.ok(block.includes('await clearOldMenus(ctx, user);'), 'Expected clearOldMenus in to_main_menu'); + assert.ok(block.includes('await sendPersistentUserMenu(ctx, user);'), 'Expected sendPersistentUserMenu in to_main_menu'); +}); + +test('play button helper is deterministic for browser and mini app modes', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + assert.ok(source.includes('function getPlayButton('), 'Expected unified getPlayButton helper'); + const helperStart = source.indexOf('function getPlayButton('); + const helperBlock = source.slice(helperStart, helperStart + 650); + assert.ok(helperBlock.includes("url: 'https://runewager.com/r/GambleCodez'"), 'Expected browser mode external URL'); + assert.ok(helperBlock.includes("web_app: { url: 'https://t.me/RuneWager_bot/Play' }"), 'Expected mini app web_app launch'); + assert.ok(!helperBlock.includes('startapp='), 'Expected no startapp launch in getPlayButton helper'); +}); + +test('all menu play buttons use unified getPlayButton path with no hardcoded mixed mode', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + assert.ok(source.includes('function playButtonMarkup('), 'Expected helper to convert getPlayButton to Markup button'); + + const playButtonUsageCount = (source.match(/\[playButtonMarkup\(user\)\]/g) || []).length; + assert.ok(playButtonUsageCount >= 2, 'Expected both main and persistent menus to use unified play button helper'); + + const quickStart = source.indexOf("bot.action('menu_qc_play'"); + assert.ok(quickStart >= 0, 'Expected quick-play callback'); + const quickBlock = source.slice(quickStart, quickStart + 350); + assert.ok(quickBlock.includes('playButtonMarkup(user)'), 'Expected quick-play to use unified play button helper'); +}); + +test('settings play mode toggle persists, clears old menus, and re-renders settings', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + const start = source.indexOf("bot.action('settings_toggle_playmode'"); + assert.ok(start >= 0, 'Expected settings_toggle_playmode callback'); + const block = source.slice(start, start + 500); + assert.ok(block.includes('persistRuntimeState();'), 'Expected play mode toggle to persist immediately'); + assert.ok(block.includes('await clearOldMenus(ctx, user);'), 'Expected play mode toggle to clear stale menus before re-render'); + assert.ok(block.includes('await sendSettingsMenu(ctx, user);'), 'Expected play mode toggle to re-render settings menu'); +}); + + +test('load_tooltips.sh exists and targets system tooltip json path', () => { + const script = fs.readFileSync(path.resolve(__dirname, '..', 'load_tooltips.sh'), 'utf8'); + assert.ok(script.includes('/var/www/html/Runewager/data/tooltips.json'), 'Expected tooltips output path'); + assert.ok(script.includes('✅ Tooltips loaded successfully'), 'Expected success message'); +}); + +test('30 SC user submenu includes required manual-review actions and copy', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + for (const token of ['w30_menu_how', 'w30_menu_eligibility', 'w30_menu_request', 'w30_my_status']) { + assert.ok(source.includes(token), `Expected 30 SC menu callback: ${token}`); + } + assert.ok(source.includes('Eligibility is reviewed manually by GambleCodez.'), 'Expected manual eligibility copy'); + assert.ok(source.includes('Your request has been submitted to GambleCodez for manual review.'), 'Expected request submission copy'); +}); + +test('30 SC admin submenu and log sink are wired', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + assert.ok(source.includes("bot.action('w30_admin_menu'"), 'Expected admin bonus submenu callback'); + for (const label of ['View Pending Requests', 'Approve Bonus', 'Deny Bonus', 'View User History', 'Reset Attempts']) { + assert.ok(source.includes(label), `Expected admin bonus menu label: ${label}`); + } + assert.ok(source.includes('/var/www/html/Runewager/logs/bonus_admin.log'), 'Expected bonus admin log file path'); +}); + +test('BOTPRIVACYMODE compatibility env var is removed', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + const envExample = fs.readFileSync(path.resolve(__dirname, '..', '.env.example'), 'utf8'); + assert.ok(!source.includes('BOTPRIVACYMODE'), 'Expected BOTPRIVACYMODE removed from runtime env parsing'); + assert.ok(!envExample.includes('BOTPRIVACYMODE'), 'Expected BOTPRIVACYMODE removed from .env.example'); +}); diff --git a/test/unit.test.js b/test/unit.test.js index 51e5c9c..48eeec0 100644 --- a/test/unit.test.js +++ b/test/unit.test.js @@ -174,6 +174,105 @@ 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 getPlayMode(user = null) { + const mode = user && user.settings ? user.settings.playMode : user && user.playMode; + return mode === 'browser' ? 'browser' : 'miniapp'; +} + +function getBrowserLink(route = 'play') { + const map = { + play: 'https://runewager.com/r/GambleCodez', + profile: 'https://www.runewager.com/profile', + claim: 'https://www.runewager.com/affiliate', + affiliate: 'https://www.runewager.com/affiliate', + }; + return map[route] || map.play; +} + +function getStartAppLink(route = 'play') { + return `https://t.me/RuneWager_bot/Play?startapp=${route}`; +} + +function getPlayLink(user, route = 'play') { + return getPlayMode(user) === 'browser' ? getBrowserLink(route) : getStartAppLink(route); +} + +function getPlayButton(user = null) { + if (getPlayMode(user) === 'browser') { + return { + text: '🔵 Play & Win', + url: 'https://runewager.com/r/GambleCodez', + }; + } + return { + text: '🔵 Play & Win', + web_app: { url: 'https://t.me/RuneWager_bot/Play' }, + }; +} + // --------------------------------------------------------------------------- // Bonus status state machine // --------------------------------------------------------------------------- @@ -328,6 +427,115 @@ 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 runewager affiliate link only for play', () => { + const user = { settings: { playMode: 'browser' } }; + const out = getPlayLink(user, 'play'); + assert.equal(out, 'https://runewager.com/r/GambleCodez'); + assert.ok(!out.includes('startapp=')); + assert.ok(!out.includes('t.me/')); +}); + +test('mini app mode returns startapp mini-app link for routes', () => { + const user = { settings: { playMode: 'miniapp' } }; + const out = getPlayLink(user, 'profile'); + assert.match(out, /^https:\/\/t\.me\//); + assert.ok(out.includes('startapp=profile')); +}); + +test('getPlayButton enforces deterministic browser and mini app launch metadata', () => { + const browser = getPlayButton({ settings: { playMode: 'browser' } }); + assert.equal(browser.text, '🔵 Play & Win'); + assert.equal(browser.url, 'https://runewager.com/r/GambleCodez'); + assert.equal(browser.web_app, undefined); + + const mini = getPlayButton({ settings: { playMode: 'miniapp' } }); + assert.equal(mini.text, '🔵 Play & Win'); + assert.deepEqual(mini.web_app, { url: 'https://t.me/RuneWager_bot/Play' }); + assert.equal(mini.url, undefined); +}); + +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')); +}); + + + +function escapeHtml(text) { + return String(text || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function referralShareHTML(code) { + return `🚨 YO! I’m farming free SC on Runewager and you need to get in NOW. + +Daily giveaways, instant rewards, drops, and a secret new-user bonus waiting inside the bot. + +Use my code ${escapeHtml(code)} when you start — we BOTH get a 2× boost on all giveaways for 7 days. + +👉 Tap here to join Runewager + +Don’t sleep. This thing prints. ⚡👑`; +} + +function applyOnboardingReferralCodeLike(user, owner, codeInput, ownerCode = 'abc123') { + const code = String(codeInput || '').trim().toLowerCase(); + if (!code) return { ok: false, reason: 'Please enter a referral code.' }; + if (!user.ageConfirmed) return { ok: false, reason: 'Referral codes can only be entered during onboarding.' }; + if ((user.referralAppliedAt || 0) > 0 || (user.referredByUserId || 0) > 0) return { ok: false, reason: 'Referral code already used on this account.' }; + if (user.onboarding && user.onboarding.completedAt) return { ok: false, reason: 'Referral code can only be entered before onboarding is complete.' }; + if (String(ownerCode).toLowerCase() !== code) return { ok: false, reason: 'Referral code not found.' }; + if (owner.id === user.id) return { ok: false, reason: 'You cannot use your own referral code.' }; + const expiresAt = 1700000000000 + 7 * 24 * 60 * 60 * 1000; + return { ok: true, ownerId: owner.id, expiresAt }; +} + // --------------------------------------------------------------------------- // Promo claim limit check (pure logic) // --------------------------------------------------------------------------- @@ -347,3 +555,59 @@ test('remaining claims decrements correctly', () => { remainingClaims = 0; assert.ok(remainingClaims <= 0); // no more claims }); + + +test('referral share html includes code', () => { + const html = referralShareHTML('rwabc'); + assert.ok(html.includes('rwabc')); + assert.ok(html.includes('2× boost')); + assert.ok(html.includes('https://t.me/RunewagerBot')); +}); + +test('referral abuse prevention blocks self-referral', () => { + const user = { id: 1, ageConfirmed: true, referralAppliedAt: 0, referredByUserId: 0, onboarding: { completedAt: 0 } }; + const owner = { id: 1 }; + const out = applyOnboardingReferralCodeLike(user, owner, 'abc123'); + assert.equal(out.ok, false); + assert.match(out.reason, /cannot use your own/i); +}); + +test('referral code accepted only during onboarding', () => { + const user = { id: 2, ageConfirmed: true, referralAppliedAt: 0, referredByUserId: 0, onboarding: { completedAt: 1700 } }; + const owner = { id: 1 }; + const out = applyOnboardingReferralCodeLike(user, owner, 'abc123'); + assert.equal(out.ok, false); + assert.match(out.reason, /before onboarding is complete/i); +}); + +test('valid referral applies boost expiry shape', () => { + const user = { id: 2, ageConfirmed: true, referralAppliedAt: 0, referredByUserId: 0, onboarding: { completedAt: 0 } }; + const owner = { id: 1 }; + const out = applyOnboardingReferralCodeLike(user, owner, 'abc123'); + assert.equal(out.ok, true); + assert.equal(out.ownerId, 1); + assert.ok(out.expiresAt > 1700000000000); +}); + + +test('manual bonus policy allows requests without auto wager/API checks', () => { + function checkBonusEligibilityLike(user) { + if (user.needsUsername) return { ok: false }; + if (user.status === 'bonus_sent') return { ok: false }; + if (user.status === 'pending' || user.status === 'approved') return { ok: false }; + if (user.attempts >= 3) return { ok: false }; + return { ok: true }; + } + + assert.equal(checkBonusEligibilityLike({ status: 'none', attempts: 0 }).ok, true); + assert.equal(checkBonusEligibilityLike({ status: 'denied', attempts: 2 }).ok, true); + assert.equal(checkBonusEligibilityLike({ status: 'bonus_sent', attempts: 1 }).ok, false); + assert.equal(checkBonusEligibilityLike({ status: 'none', attempts: 3 }).ok, false); +}); + + +test('referralShareHTML escapes HTML-sensitive referral codes', () => { + const html = referralShareHTML(''); + assert.ok(!html.includes('