diff --git a/.env.example b/.env.example index ad1845b..62a187e 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,7 @@ ADMIN_IDS=YOUR_TELEGRAM_USER_ID AFFILIATE_SOURCE=GambleCodez ANNOUNCE_CHANNEL="-1002648883359" -BOTPRIVACYMODE="disabled" -# BOT_PRIVACY_MODE="disabled" in BotFather/non-privacy mode so bot can read group messages. +# Set in BotFather/non-privacy mode so bot can read group messages. BOT_PRIVACY_MODE="disabled" BOT_TOKEN= DEVICE=vps diff --git a/CLAUDE.md b/CLAUDE.md index 8780676..f0826da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,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 e1e05f3..44542bf 100644 --- a/RUNEWAGER_FUNCTIONALITY_MAP.md +++ b/RUNEWAGER_FUNCTIONALITY_MAP.md @@ -194,7 +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) and compatibility `BOTPRIVACYMODE`, plus Telegram/HTTPS/discovery keys used by runtime checks. +- `.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. @@ -353,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 41cd0b2..031b442 100644 --- a/index.js +++ b/index.js @@ -50,12 +50,17 @@ function getDiscordLink(url) { 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://gamble-codez.com/runewager', - profile: 'https://gamble-codez.com/runewager/profile', - claim: 'https://gamble-codez.com/runewager/claim', - affiliate: 'https://gamble-codez.com/runewager/affiliate', + 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; @@ -71,8 +76,26 @@ function getStartAppLink(route = 'play') { } function getPlayLink(user, route = 'play') { - const mode = user && user.settings ? user.settings.playMode : 'miniapp'; - return mode === 'browser' ? getBrowserLink(route) : getStartAppLink(route); + 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 = { @@ -105,7 +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 || process.env.BOTPRIVACYMODE || '').trim().toLowerCase(); +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 || ''; @@ -192,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. @@ -276,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'); @@ -364,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)) @@ -373,6 +421,15 @@ 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); @@ -2356,14 +2413,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 }; } @@ -2382,25 +2432,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.`); } /** @@ -2424,6 +2461,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}`, @@ -2513,10 +2552,8 @@ function promoButton() { */ -function miniAppPlayButton(user = null, route = 'play') { - const target = getPlayLink(user, route); - const inBrowser = (user && user.settings && user.settings.playMode === 'browser'); - return Markup.button.url(inBrowser ? '๐ŸŒ Open Runewager (Browser)' : '๐ŸŽฎ Open Runewager (Mini App)', target); +function miniAppPlayButton(user = null) { + return playButtonMarkup(user); } /** @@ -2526,8 +2563,6 @@ function miniAppPlayButton(user = null, route = 'play') { * @param {object} user - user object for settings-aware button labels */ function mainMenuKeyboard(isAdminUser = false, page = 1, user = null) { - const playUrl = getPlayLink(user, 'play'); - if (page === 2) { // โ”€โ”€ Page 2 โ€” Community & Extras โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const rows = [ @@ -2576,7 +2611,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'), @@ -3253,6 +3288,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')], @@ -3756,10 +3792,8 @@ async function sendMainMenu(ctx, user, page = 1) { /** Keyboard for the new persistent USER MAIN MENU */ function userMainMenuKeyboard(isAdminUser, user = null) { - const playUrl = getPlayLink(user, 'play'); - const browserMode = user && user.settings && user.settings.playMode === 'browser'; const rows = [ - [Markup.button.url(browserMode ? '๐ŸŒ Play Runewager (Browser)' : '๐ŸŽฎ Play Runewager', playUrl)], + [playButtonMarkup(user)], [ Markup.button.callback('๐ŸŽ Bonuses & Rewards', 'pmenu_claim_bonus'), Markup.button.callback('๐Ÿ‘ค Profile & Link', 'pmenu_my_profile'), @@ -5359,6 +5393,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; } @@ -5368,6 +5403,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); }); @@ -5375,12 +5411,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); }); @@ -5884,6 +5922,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'); @@ -5902,6 +5941,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 }); @@ -6697,6 +6737,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'); @@ -6863,7 +6904,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([ @@ -7016,6 +7057,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); }); @@ -7105,7 +7148,7 @@ ${lines.join('\n')}`, Markup.inlineKeyboard([[Markup.button.callback('๐Ÿ”— Group bot.action('menu_qc_play', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); - await ctx.reply('๐ŸŽฎ Open Runewager:', Markup.inlineKeyboard([[miniAppPlayButton(user)], [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) => { @@ -7122,7 +7165,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) => { @@ -7585,6 +7670,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; } @@ -7792,6 +7878,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; } @@ -8381,6 +8468,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); @@ -8438,6 +8526,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 }); @@ -8709,6 +8798,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.'); @@ -8958,6 +9048,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(); @@ -12294,12 +12403,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 */ } } @@ -12335,9 +12446,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 */ } } diff --git a/load_tooltips.sh b/load_tooltips.sh new file mode 100755 index 0000000..0d3a535 --- /dev/null +++ b/load_tooltips.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +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 + +python -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 f68b91e..5455250 100644 --- a/test/smoke.test.js +++ b/test/smoke.test.js @@ -552,11 +552,67 @@ test('to_main_menu clears old menus before rendering persistent menu', () => { assert.ok(block.includes('await sendPersistentUserMenu(ctx, user);'), 'Expected sendPersistentUserMenu in to_main_menu'); }); -test('persistent user menu play button uses settings-aware getPlayLink', () => { +test('play button helper is deterministic for browser and mini app modes', () => { const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); - const start = source.indexOf('function userMainMenuKeyboard('); - assert.ok(start >= 0, 'Expected userMainMenuKeyboard'); + 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("getPlayLink(user, 'play')"), 'Expected settings-aware play link in persistent menu'); - assert.ok(block.includes('Play Runewager (Browser)'), 'Expected browser-mode label for persistent play button'); + 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 09d1810..aa7d322 100644 --- a/test/unit.test.js +++ b/test/unit.test.js @@ -237,12 +237,17 @@ function getDiscordLink(url) { 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://gamble-codez.com/runewager', - profile: 'https://gamble-codez.com/runewager/profile', - claim: 'https://gamble-codez.com/runewager/claim', - affiliate: 'https://gamble-codez.com/runewager/affiliate', + 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; } @@ -252,8 +257,20 @@ function getStartAppLink(route = 'play') { } function getPlayLink(user, route = 'play') { - const mode = user && user.settings ? user.settings.playMode : 'miniapp'; - return mode === 'browser' ? getBrowserLink(route) : getStartAppLink(route); + 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' }, + }; } // --------------------------------------------------------------------------- @@ -449,21 +466,33 @@ test('pending action timeout uses fallback type label when missing', () => { -test('browser mode returns external gamble-codez link only', () => { +test('browser mode returns external runewager affiliate link only for play', () => { const user = { settings: { playMode: 'browser' } }; const out = getPlayLink(user, 'play'); - assert.match(out, /^https:\/\/gamble-codez\.com\//); + assert.equal(out, 'https://runewager.com/r/GambleCodez'); assert.ok(!out.includes('startapp=')); assert.ok(!out.includes('t.me/')); }); -test('telegram mode returns startapp mini-app link only', () => { +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); @@ -550,3 +579,19 @@ test('valid referral applies boost expiry shape', () => { 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); +});