From ad977b67952ae225ad667e67918a53a86477019e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 15:53:16 +0000 Subject: [PATCH 1/2] feat: admin dashboard upgrade, giveaway wizard, stats layer, group commands fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADMIN DASHBOARD - /admin in DM now shows full rich dashboard with status lights, uptime, deploy time (from DEPLOY_TIME env or .release_info), active user counts (5m / 10m windows), giveaway stats, and error count - /admin in group sends a compact inline button to open DM dashboard - Added Stats submenu button to adminDashboardKeyboard (pages 1 and 2) - New adminStatsKeyboard() and buildAdminStatusText() helpers - Removed duplicate admin_dashboard_action handler (was registered twice) STATS / ANALYTICS LAYER - New buildStatsWindow(windowMs) for O(n) time-windowed queries: new users, active users, interactions, promo claims, bonus sent, giveaways created, giveaway participants - New admin_stats_menu / admin_stats_24h / admin_stats_7d / admin_stats_30d / admin_stats_lifetime inline action handlers - Added lastSeenAt field to user records; updated on every getUser() call so active-user windows work correctly - buildDashboard() now includes activeUsers5m, activeUsers10m, runningGiveaways, totalGiveaways - trackAnalytics('promo_claimed') now emitted so window queries count it GIVEAWAY WIZARD — full inline-button flow - New /giveaway command: non-admins see active giveaway list; admins get a wizard start menu (group) or full wizard (DM) - /start_giveaway now launches the inline wizard (not text-only) - 8-step wizard: title → winners → SC → duration → minParticipants → join surface → requirements toggles → confirm - Preset buttons for each numeric step; "Custom" option drops to text input - Requirements step: tap-to-toggle inline keyboard (editMessageReplyMarkup) - Confirm step reuses existing gw_create_yes / gw_create_no handlers GIVEAWAY ENGINE UPGRADES - New fields: title, minParticipants, joinSurface ('group'|'dm'|'both'), pinnedMsgId, extendedCount — all serialised / deserialised cleanly - announceGiveaway() now: • Pins announcement in group (unpins on finalise); ignores permission errors • If surface=dm or both: broadcasts DM join message to all opted-in users • If surface includes both: shows inline group button + t.me deep-link URL - finalizeGiveaway(gwId, forceEnd) now checks minParticipants: • If unmet and extended < 2 times: DMs admins with Extend/Force-end buttons • gw_auto_extend_N: adds 15 min, increments extendedCount • gw_force_end_N: finalises regardless of participant count • Unpins announcement message on end - /start deep link handler: ?start=join_gw_ auto-joins the giveaway from DM (eligibility checked, duplicate prevented) GROUP COMMANDS FIX (interrupted task) - Expanded groupCommands from 3 to 17 entries — all relevant commands now appear when users type / in a group chat (Telegram command picker) https://claude.ai/code/session_01X3PxGFF5zzKptQwVkjYzzN --- index.js | 890 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 842 insertions(+), 48 deletions(-) diff --git a/index.js b/index.js index 969daae..11bc1ee 100644 --- a/index.js +++ b/index.js @@ -298,6 +298,12 @@ function serializeGiveaway(giveaway) { durationMinutes: Number(giveaway.durationMinutes) || 1, maxWinners: Number(giveaway.maxWinners) || 1, scPerWinner: Number(giveaway.scPerWinner) || 0, + minParticipants: Number(giveaway.minParticipants) || 0, + title: giveaway.title || '', + // joinSurface: 'group' | 'dm' | 'both' — where users can join + joinSurface: giveaway.joinSurface || 'group', + // pinnedMsgId: message_id of pinned announcement in group (if applicable) + pinnedMsgId: giveaway.pinnedMsgId || null, requireChannel: Boolean(giveaway.requireChannel), requireGroup: Boolean(giveaway.requireGroup), requireLinked: Boolean(giveaway.requireLinked), @@ -312,6 +318,7 @@ function serializeGiveaway(giveaway) { participants: Array.from((giveaway.participants || new Map()).entries()), winners: Array.isArray(giveaway.winners) ? giveaway.winners : [], paidOut: Boolean(giveaway.paidOut), + extendedCount: Number(giveaway.extendedCount) || 0, }; } @@ -430,6 +437,7 @@ function createDefaultUser(user) { showQuickCommands: true, // show quick-access row under menu tooltipsEnabled: false, // show tooltip hints in help booklet }, + lastSeenAt: Date.now(), wagerBonus: { attempts: 0, // total requests submitted; max 3 status: 'none', // none | pending | approved | denied | bonus_sent @@ -450,6 +458,7 @@ function getUser(ctx) { user.tgUsername = ctx.from.username || user.tgUsername; user.firstName = ctx.from.first_name || user.firstName; user.interactedAtLeastOnce = true; + user.lastSeenAt = Date.now(); if (!user.wagerBonus) { user.wagerBonus = { attempts: 0, @@ -844,7 +853,7 @@ function adminDashboardKeyboard(page = 1) { ], [ Markup.button.callback('📨 Broadcast', 'admin_broadcast'), - Markup.button.callback('👁 View Logs', 'admin_cmd_view_logs'), + Markup.button.callback('📈 Stats', 'admin_stats_menu'), ], [ Markup.button.callback('🛠 Run TestAll', 'admin_cmd_testall'), @@ -868,12 +877,28 @@ function adminDashboardKeyboard(page = 1) { ], [ Markup.button.callback('🛟 Support Tools', 'admin_cat_support'), - Markup.button.callback('📄 Admin Help', 'help_tab_admin'), + Markup.button.callback('📈 Stats', 'admin_stats_menu'), ], [ - Markup.button.callback('⬅️ Back to User Menu', 'to_main_menu'), + Markup.button.callback('📄 Admin Help', 'help_tab_admin'), Markup.button.callback('▶️ Quick Actions', 'admin_dash_page_2'), ], + [Markup.button.callback('⬅️ Back to User Menu', 'to_main_menu')], + ]); +} + +/** Keyboard for the stats time-window submenu */ +function adminStatsKeyboard() { + return Markup.inlineKeyboard([ + [ + Markup.button.callback('🕐 Last 24h', 'admin_stats_24h'), + Markup.button.callback('📅 Last 7 days', 'admin_stats_7d'), + ], + [ + Markup.button.callback('📆 Last 30 days', 'admin_stats_30d'), + Markup.button.callback('♾️ Lifetime', 'admin_stats_lifetime'), + ], + [Markup.button.callback('⬅️ Admin Dashboard', 'open_admin_dashboard')], ]); } @@ -929,12 +954,60 @@ function adminSupportToolsKeyboard() { ]); } +/** Build a rich status text block for the admin dashboard */ +function buildAdminStatusText() { + const dash = buildDashboard(); + const uptimeSec = Math.floor(process.uptime()); + const uptimeH = Math.floor(uptimeSec / 3600); + const uptimeM = Math.floor((uptimeSec % 3600) / 60); + const uptimeStr = uptimeH > 0 ? `${uptimeH}h ${uptimeM}m` : `${uptimeM}m`; + + // Status light: degraded if last persist > 5 min ago or 0 users in store + const lastPersistAge = lastPersistAt ? Math.floor((Date.now() - lastPersistAt) / 1000) : 99999; + const botStatus = lastPersistAge < 300 ? '🟢 OK' : '🟡 Degraded'; + + // Deploy time — prefer env var injected by CI, fall back to .release_info file + let deployTime = process.env.DEPLOY_TIME || ''; + if (!deployTime) { + try { + const releaseFile = path.join(PROJECT_DIR, '.release_info'); + if (fs.existsSync(releaseFile)) { + const releaseRaw = JSON.parse(fs.readFileSync(releaseFile, 'utf8')); + deployTime = releaseRaw.deployedAt || releaseRaw.timestamp || ''; + } + } catch (_) { /* .release_info optional */ } + } + + const lines = [ + `🛠 *Admin Dashboard*`, + ``, + `*Status*`, + ` Bot: ${botStatus}`, + ` Uptime: ${uptimeStr}`, + deployTime ? ` Deployed: ${deployTime}` : null, + ` Version: ${pkgVersion}`, + ``, + `*Users*`, + ` Total: ${dash.totalUsers}`, + ` Online now (5m): ${dash.activeUsers5m}`, + ` Active (10m): ${dash.activeUsers10m}`, + ` Onboarded: ${dash.onboardingDone}`, + ``, + `*Activity*`, + ` Promo claims: ${dash.promoClaims}`, + ` Referral users: ${dash.referralUsers}`, + ` Giveaways running: ${dash.runningGiveaways}`, + ` Giveaways total: ${dash.totalGiveaways}`, + ` Errors logged: ${dash.errors}`, + ].filter((l) => l !== null).join('\n'); + return lines; +} + async function sendAdminDashboard(ctx, page = 1) { const pageLabel = page === 2 ? 'Page 2/2: Quick Actions' : 'Page 1/2: Categories'; - const dash = buildDashboard(); - const statsLine = `👥 Users: ${dash.totalUsers} | 📋 Promo claims: ${dash.promoClaims} | ✅ Onboarded: ${dash.onboardingDone}`; + const statusText = buildAdminStatusText(); await ctx.reply( - `🛠 *Admin Dashboard* — ${pageLabel}\n\n${statsLine}\n\nSelect a category or quick action:`, + `${statusText}\n\n_Page: ${pageLabel}_\n\nSelect a category or quick action:`, { parse_mode: 'Markdown', ...adminDashboardKeyboard(page) }, ); } @@ -1188,19 +1261,66 @@ setInterval(() => { } }, 10 * 60 * 1000); +/** + * Compute analytics for a given time window (milliseconds from now). + * Efficient O(n) scan of in-memory stores — no heavy per-message cost. + */ +function buildStatsWindow(windowMs) { + const cutoff = windowMs > 0 ? Date.now() - windowMs : 0; // 0 means lifetime + const allUsers = Array.from(userStore.values()); + const newUsers = allUsers.filter((u) => (u.onboarding && u.onboarding.startedAt || 0) > cutoff).length; + const activeUsers = allUsers.filter((u) => (u.lastSeenAt || 0) > cutoff).length; + const events = windowMs > 0 + ? analyticsStore.events.filter((e) => e.ts > cutoff) + : analyticsStore.events; + const interactions = events.length; + const promoClaims = windowMs > 0 + ? events.filter((e) => e.event === 'promo_claimed').length + : promoStore.claimsByUser.size; + const allGws = [ + ...Array.from(giveawayStore.running.values()), + ...Array.from(giveawayStore.history.values()), + ]; + const windowGws = allGws.filter((g) => (g.createdAt || 0) > cutoff); + const giveawaysCreated = windowGws.length; + const giveawayParticipants = windowGws.reduce((sum, g) => sum + (g.participants ? g.participants.size : 0), 0); + const bonusSent = allUsers.filter((u) => { + if (!u.wagerBonus || u.wagerBonus.status !== 'bonus_sent') return false; + return windowMs > 0 ? (u.wagerBonus.updatedAt || 0) > cutoff : true; + }).length; + return { + newUsers, + activeUsers, + interactions, + promoClaims, + giveawaysCreated, + giveawayParticipants, + bonusSent, + }; +} + function buildDashboard() { + const now = Date.now(); + const allUsers = Array.from(userStore.values()); const totalUsers = userStore.size; - const activeUsers = Array.from(userStore.values()).filter((u) => Date.now() - u.onboarding.startedAt < 7 * 24 * 60 * 60 * 1000).length; + // Active in last 7 days (for backward compat) and last 10 min + const activeUsers = allUsers.filter((u) => (u.lastSeenAt || 0) > now - 7 * 24 * 60 * 60 * 1000).length; + const activeUsers10m = allUsers.filter((u) => (u.lastSeenAt || 0) > now - 10 * 60 * 1000).length; + const activeUsers5m = allUsers.filter((u) => (u.lastSeenAt || 0) > now - 5 * 60 * 1000).length; const promoClaims = promoStore.claimsByUser.size; - const onboardingDone = Array.from(userStore.values()).filter((u) => u.onboarding && u.onboarding.completedAt).length; + const onboardingDone = allUsers.filter((u) => u.onboarding && u.onboarding.completedAt).length; const data = { generatedAt: new Date().toISOString(), totalUsers, activeUsers, + activeUsers10m, + activeUsers5m, promoClaims, onboardingDone, errors: analyticsStore.events.filter((e) => e.event === 'error').length, referralUsers: referralStore.stats.size, + runningGiveaways: giveawayStore.running.size, + totalGiveaways: giveawayStore.history.size + giveawayStore.running.size, }; saveJson(dashboardFile, data); return data; @@ -1249,10 +1369,26 @@ async function configureBotSurface() { { command: 'cancel', description: 'Cancel pending input' }, { command: 'claim_history', description: 'View promo claim history' }, ]); + // Group commands: all commands users will want to type in a group chat. + // Telegram shows this list when a user types / in the group. const groupCommands = [ + { command: 'play', description: 'Launch Runewager Mini App' }, + { command: 'start', description: 'Start / link your account (opens in DM)' }, + { command: 'signup', description: 'Sign up under GambleCodez affiliate' }, + { command: 'promo', description: 'View current promo code and bonus' }, { command: 'leaderboard', description: 'Global referral leaderboard' }, { command: 'leaderboard_weekly', description: 'Weekly referral leaderboard' }, - { command: 'start_giveaway', description: '(Admin) Start SC giveaway' }, + { command: 'referral', description: 'Get your referral link' }, + { command: 'status', description: 'Quick account status' }, + { command: 'profile', description: 'View your profile' }, + { command: 'bonus', description: 'View 30 SC wager bonus' }, + { command: 'linkrunewager', description: 'Link your Runewager username' }, + { command: 'discord', description: 'Open Runewager Discord' }, + { command: 'help', description: 'Help and commands' }, + { command: 'giveaway', description: 'View / join active giveaways' }, + { command: 'spin', description: 'Daily bonus wheel' }, + { command: 'affiliate', description: 'Open affiliate / promo entry page' }, + { command: 'bugreport', description: 'Report a bug or issue' }, ]; await bot.telegram.setMyCommands(globalCommands, { scope: { type: 'default' } }).catch(() => {}); await bot.telegram.setMyCommands(privateCommands, { scope: { type: 'all_private_chats' } }).catch(() => {}); @@ -1263,10 +1399,11 @@ async function configureBotSurface() { // eslint-disable-next-line no-await-in-loop await bot.telegram.setMyCommands([ ...privateCommands, - { command: 'admin', description: 'Admin promo controls panel' }, + { command: 'admin', description: 'Admin dashboard (full)' }, { command: 'admin_help', description: 'Admin help guide with all commands' }, { command: 'admin_dashboard', description: 'Live user & promo dashboard' }, - { command: 'start_giveaway', description: 'Start SC giveaway wizard' }, + { command: 'giveaway', description: 'Create / manage giveaways' }, + { command: 'start_giveaway', description: 'Start SC giveaway wizard (alias)' }, { command: 'bonus', description: '30 SC bonus: pending / approve / deny / sent / add' }, { command: 'wager30_admin', description: '30 SC bonus admin panel (alias)' }, { command: 'admin_backup', description: 'Create runtime state backup snapshot' }, @@ -1328,6 +1465,37 @@ bot.start(safeStepHandler('start', async (ctx) => { ])); } + // Handle giveaway DM join deep links: ?start=join_gw_ + if (payload.startsWith('join_gw_')) { + const gwId = Number(payload.slice(8)); + if (gwId > 0) { + const giveaway = giveawayStore.running.get(gwId); + if (!giveaway) { + await ctx.reply('⚠️ That giveaway has already ended or does not exist.'); + } else if (giveaway.participants.has(user.id)) { + await ctx.reply(`✅ You are already entered in Giveaway #${gwId}!`); + } else { + const elig = evaluateEligibility(user, giveaway); + if (!elig.ok) { + const replyOpts = elig.keyboard ? elig.keyboard : {}; + await ctx.reply(`❌ ${elig.message}`, replyOpts); + } else { + giveaway.participants.set(user.id, { + userId: user.id, + tgUsername: user.tgUsername, + firstName: user.firstName, + runewagerUsername: user.runewagerUsername, + joinedAt: Date.now(), + }); + user.giveawayJoinedIds.add(gwId); + trackAnalytics('giveaway_join', { gwId, userId: user.id }); + await ctx.reply(`🎉 You have entered Giveaway #${gwId}!\n\nPrize: ${giveaway.scPerWinner} SC\nParticipants so far: ${giveaway.participants.size}`); + } + } + return; + } + } + trackOnboardingProgress(user); if (!user.ageConfirmed) { @@ -1687,7 +1855,17 @@ bot.command('walkthrough', async (ctx) => { bot.command('admin', async (ctx) => { if (!requireAdmin(ctx)) return; - await ctx.reply('🔧 Admin Controls', adminKeyboard()); + const isGroup = ctx.chat && (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'); + if (isGroup) { + // In groups: send a brief inline button so admin can open dashboard in DM + await ctx.reply( + '🛠 Admin — tap to open the full dashboard in DM.', + Markup.inlineKeyboard([[Markup.button.callback('📊 Open Admin Dashboard', 'open_admin_dashboard')]]), + ); + return; + } + // In DM: send the full rich dashboard + await sendAdminDashboard(ctx, 1); }); bot.command('admin_help', async (ctx) => { @@ -1696,12 +1874,209 @@ bot.command('admin_help', async (ctx) => { await sendHelpMenu(ctx, user, 'admin'); }); +// ========================= +// Giveaway Wizard — Inline keyboard-driven creation flow +// Triggered by /giveaway command (admin) or "Start Giveaway" button. +// Steps: title → winners → sc → duration → minParts → surface → reqs → confirm +// ========================= + +/** Keyboard for step: number of winners */ +function gwizWinnersKeyboard() { + return Markup.inlineKeyboard([ + [ + Markup.button.callback('1', 'gwiz_winners_1'), + Markup.button.callback('2', 'gwiz_winners_2'), + Markup.button.callback('3', 'gwiz_winners_3'), + Markup.button.callback('5', 'gwiz_winners_5'), + ], + [Markup.button.callback('✏️ Custom (type it)', 'gwiz_winners_custom')], + [Markup.button.callback('❌ Cancel', 'gwiz_cancel')], + ]); +} + +/** Keyboard for step: SC per winner */ +function gwizScKeyboard() { + return Markup.inlineKeyboard([ + [ + Markup.button.callback('5 SC', 'gwiz_sc_5'), + Markup.button.callback('10 SC', 'gwiz_sc_10'), + Markup.button.callback('25 SC', 'gwiz_sc_25'), + Markup.button.callback('50 SC', 'gwiz_sc_50'), + ], + [Markup.button.callback('✏️ Custom (type it)', 'gwiz_sc_custom')], + [Markup.button.callback('❌ Cancel', 'gwiz_cancel')], + ]); +} + +/** Keyboard for step: countdown duration */ +function gwizDurationKeyboard() { + return Markup.inlineKeyboard([ + [ + Markup.button.callback('5 min', 'gwiz_dur_5'), + Markup.button.callback('15 min', 'gwiz_dur_15'), + Markup.button.callback('30 min', 'gwiz_dur_30'), + Markup.button.callback('60 min', 'gwiz_dur_60'), + ], + [ + Markup.button.callback('2 hr', 'gwiz_dur_120'), + Markup.button.callback('4 hr', 'gwiz_dur_240'), + Markup.button.callback('✏️ Custom', 'gwiz_dur_custom'), + ], + [Markup.button.callback('❌ Cancel', 'gwiz_cancel')], + ]); +} + +/** Keyboard for step: minimum participants */ +function gwizMinPartsKeyboard() { + return Markup.inlineKeyboard([ + [ + Markup.button.callback('None', 'gwiz_minp_0'), + Markup.button.callback('5', 'gwiz_minp_5'), + Markup.button.callback('10', 'gwiz_minp_10'), + Markup.button.callback('20', 'gwiz_minp_20'), + ], + [Markup.button.callback('✏️ Custom', 'gwiz_minp_custom')], + [Markup.button.callback('❌ Cancel', 'gwiz_cancel')], + ]); +} + +/** Keyboard for step: join surface (where users can enter) */ +function gwizSurfaceKeyboard(selected) { + const mark = (s) => (selected === s ? '✅ ' : ''); + return Markup.inlineKeyboard([ + [Markup.button.callback(`${mark('group')}📣 Group Only`, 'gwiz_surface_group')], + [Markup.button.callback(`${mark('dm')}💬 DM Only`, 'gwiz_surface_dm')], + [Markup.button.callback(`${mark('both')}🌐 Both (Group + DM)`, 'gwiz_surface_both')], + [Markup.button.callback('❌ Cancel', 'gwiz_cancel')], + ]); +} + +/** + * Keyboard for step: eligibility requirements (toggles). + * data holds the current wizard data including requirement booleans. + */ +function gwizReqsKeyboard(data) { + const chk = (v) => (v ? '✅ ' : '☐ '); + return Markup.inlineKeyboard([ + [Markup.button.callback(`${chk(data.requireLinked)}Linked Username`, 'gwiz_req_linked')], + [Markup.button.callback(`${chk(data.requireChannel)}Channel Join`, 'gwiz_req_channel')], + [Markup.button.callback(`${chk(data.requireGroup)}Group Join`, 'gwiz_req_group')], + [Markup.button.callback(`${chk(data.requireAge)}Age Confirmed`, 'gwiz_req_age')], + [Markup.button.callback(`${chk(data.requireVerified)}Account Verified`, 'gwiz_req_verified')], + [Markup.button.callback(`${chk(data.requirePromo)}Promo Claimed`, 'gwiz_req_promo')], + [Markup.button.callback('✅ Done — Next Step →', 'gwiz_reqs_done')], + [Markup.button.callback('❌ Cancel', 'gwiz_cancel')], + ]); +} + +/** Show giveaway wizard confirmation summary */ +function gwizSummaryText(d) { + return [ + `🎉 *Giveaway Setup — Review*`, + ``, + d.title ? `📌 Title: ${d.title}` : `📌 Title: _(default)_`, + `🏆 Winners: ${d.maxWinners}`, + `💰 SC each: ${d.scPerWinner}`, + `⏱ Duration: ${d.durationMinutes} min`, + `👥 Min participants: ${d.minParticipants || 'none'}`, + `📣 Join surface: ${d.joinSurface}`, + ``, + `*Requirements:*`, + ` Linked username: ${d.requireLinked ? '✅' : '☐'}`, + ` Channel join: ${d.requireChannel ? '✅' : '☐'}`, + ` Group join: ${d.requireGroup ? '✅' : '☐'}`, + ` Age (18+): ${d.requireAge ? '✅' : '☐'}`, + ` Verified account: ${d.requireVerified ? '✅' : '☐'}`, + ` Promo claimed: ${d.requirePromo ? '✅' : '☐'}`, + ``, + `Tap *Launch!* to start or *Cancel* to abort.`, + ].join('\n'); +} + +/** + * Start the inline giveaway wizard. + * config = { chatId, chatTitle, startedBy } + */ +async function gwizStart(ctx, user, config) { + clearPendingAction(user); + const data = { + chatId: config.chatId, + chatTitle: config.chatTitle, + startedBy: config.startedBy, + title: '', + maxWinners: 1, + scPerWinner: 10, + durationMinutes: 30, + minParticipants: 0, + joinSurface: 'group', + requireLinked: true, + requireChannel: false, + requireGroup: false, + requireAge: false, + requireVerified: false, + requirePromo: false, + requireWalkthrough: false, + requireRefTag: '', + testMode: false, + dryRun: false, + _step: 'title', + }; + user.pendingAction = { type: 'gwiz_await_title', data }; + await ctx.reply( + `🎉 *New Giveaway Wizard*\n\n*Step 1:* Enter a title or description for this giveaway (e.g. "Weekend SC Drop").\n\nOr tap Skip to use the default title.`, + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('⏭ Skip (default title)', 'gwiz_title_skip')], + [Markup.button.callback('❌ Cancel', 'gwiz_cancel')], + ]), + }, + ); +} + +bot.command('giveaway', async (ctx) => { + const isAdmin = ADMIN_IDS.includes(Number(ctx.from.id)); + const isGroup = ctx.chat && (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'); + + if (!isAdmin) { + // Non-admins: show active giveaways list + await sendGiveawayListPage(ctx, 1); + return; + } + + // Admin in a group: offer to start giveaway for this group or list active ones + if (isGroup) { + const running = Array.from(giveawayStore.running.values()); + const thisGroupGws = running.filter((g) => g.chatId === ctx.chat.id); + const activeNote = thisGroupGws.length + ? `\n\n🔴 ${thisGroupGws.length} active giveaway(s) in this group.` + : '\n\nNo active giveaways in this group.'; + await ctx.reply( + `🎉 *Giveaway* — Admin Menu${activeNote}`, + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('🎁 Start Giveaway Here', 'gwiz_start_here')], + [Markup.button.callback('📊 Active Giveaways', 'admin_cmd_giveaway_status')], + [Markup.button.callback('❌ Close', 'admin_cancel')], + ]), + }, + ); + return; + } + + // Admin in DM: full wizard + const user = getUser(ctx); + await gwizStart(ctx, user, { chatId: ctx.chat.id, chatTitle: 'DM', startedBy: ctx.from.id }); +}); + bot.command('start_giveaway', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); - clearPendingAction(user); - user.pendingAction = { type: 'gw_winners', data: { chatId: ctx.chat.id, chatTitle: ctx.chat.title || 'DM', startedBy: ctx.from.id } }; - await ctx.reply('How many winners? (number)'); + // If in group, start wizard for this group; otherwise DM wizard + const chatId = ctx.chat.id; + const chatTitle = ctx.chat.title || 'DM'; + await gwizStart(ctx, user, { chatId, chatTitle, startedBy: ctx.from.id }); }); bot.command('cancel', async (ctx) => { @@ -2557,6 +2932,7 @@ bot.action('promo_confirm_claimed', async (ctx) => { registerPromoClaim(user, promoStore.code || 'unknown'); addBadge(user, 'promo_claimed'); user.profileXP += 20; + trackAnalytics('promo_claimed', { userId: user.id, code: promoStore.code }); trackOnboardingProgress(user); await ctx.answerCbQuery('Saved'); await ctx.reply( @@ -2940,6 +3316,66 @@ bot.action('admin_cmd_exportbugs', async (ctx) => { await ctx.reply('Use /exportbugs to export all bug reports.'); }); +// ── Admin stats submenu ──────────────────────────────────────────────────── + +/** Build a formatted stats text for a given window */ +function buildStatsText(label, windowMs) { + const s = buildStatsWindow(windowMs); + return [ + `📈 *Stats — ${label}*`, + ``, + `New users: ${s.newUsers}`, + `Active users: ${s.activeUsers}`, + `Interactions tracked: ${s.interactions}`, + `Promo claims: ${s.promoClaims}`, + `Bonus sent: ${s.bonusSent}`, + `Giveaways created: ${s.giveawaysCreated}`, + `Giveaway participants: ${s.giveawayParticipants}`, + ].join('\n'); +} + +bot.action('admin_stats_menu', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await ctx.reply('📈 *Stats — Select Time Window*', { parse_mode: 'Markdown', ...adminStatsKeyboard() }); +}); + +bot.action('admin_stats_24h', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await ctx.reply(buildStatsText('Last 24 hours', 24 * 60 * 60 * 1000), { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([[Markup.button.callback('⬅️ Back', 'admin_stats_menu')]]), + }); +}); + +bot.action('admin_stats_7d', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await ctx.reply(buildStatsText('Last 7 days', 7 * 24 * 60 * 60 * 1000), { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([[Markup.button.callback('⬅️ Back', 'admin_stats_menu')]]), + }); +}); + +bot.action('admin_stats_30d', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await ctx.reply(buildStatsText('Last 30 days', 30 * 24 * 60 * 60 * 1000), { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([[Markup.button.callback('⬅️ Back', 'admin_stats_menu')]]), + }); +}); + +bot.action('admin_stats_lifetime', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await ctx.reply(buildStatsText('Lifetime', 0), { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([[Markup.button.callback('⬅️ Back', 'admin_stats_menu')]]), + }); +}); + bot.action('menu_admin_tab', async (ctx) => { const user = getUser(ctx); if (!ADMIN_IDS.includes(Number(user.id))) { await ctx.answerCbQuery('Admin only.'); return; } @@ -2959,14 +3395,8 @@ bot.action('menu_admin_tab', async (ctx) => { ); }); -bot.action('admin_dashboard_action', async (ctx) => { - if (!ADMIN_IDS.includes(Number(ctx.from.id))) { await ctx.answerCbQuery('Admin only.'); return; } - await ctx.answerCbQuery(); - const dashboard = buildDashboard(); - await ctx.reply( - `Admin Live Dashboard\nUsers: ${dashboard.totalUsers}\nActive: ${dashboard.activeUsers}\nOnboarding complete: ${dashboard.onboardingDone}\nPromo claims: ${dashboard.promoClaims}\nErrors: ${dashboard.errors}\nReferral users: ${dashboard.referralUsers}\nGenerated: ${dashboard.generatedAt}`, - ); -}); +// admin_dashboard_action: second registration — sends the rich dashboard (replaces the minimal version above) +bot.action('admin_dashboard_action_v2_noop', () => {}); // placeholder to document merge bot.action('admin_auth_bypass', async (ctx) => { const user = getUser(ctx); @@ -3401,6 +3831,27 @@ bot.action(/^gw_edit_sc_(\d+)$/, async (ctx) => { await ctx.reply('Enter new SC per winner:'); }); +bot.action(/^gw_auto_extend_(\d+)$/, async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery('Extending...'); + const gwId = Number(ctx.match[1]); + const giveaway = giveawayStore.running.get(gwId); + if (!giveaway) { await ctx.reply(`Giveaway #${gwId} is no longer running.`); return; } + giveaway.endTime += 15 * 60 * 1000; + giveaway.extendedCount = (giveaway.extendedCount || 0) + 1; + resetGiveawayTimer(giveaway); + await ctx.reply(`✅ Giveaway #${gwId} extended by 15 minutes (extension ${giveaway.extendedCount}/2).`); + await bot.telegram.sendMessage(giveaway.chatId, `⏱ Giveaway #${gwId} extended! ${15} minutes added — not enough participants yet.`).catch(() => {}); +}); + +bot.action(/^gw_force_end_(\d+)$/, async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery('Ending...'); + const gwId = Number(ctx.match[1]); + await finalizeGiveaway(gwId, true); + await ctx.reply(`✅ Giveaway #${gwId} finalized (forced end).`); +}); + bot.action(/^gw_export_(\d+)$/, async (ctx) => { if (!requireAdmin(ctx)) return; const gwId = Number(ctx.match[1]); @@ -3772,7 +4223,55 @@ bot.on('text', async (ctx) => { return; } - // Giveaway creation wizard + // ── Inline giveaway wizard custom-text inputs ────────────────────────────── + if (action.type === 'gwiz_await_title') { + if (!requireAdmin(ctx)) return; + action.data.title = text.slice(0, 100); + user.pendingAction = { type: 'gwiz_step_winners', data: action.data }; + await ctx.reply('*Step 2:* How many winners?', { parse_mode: 'Markdown', ...gwizWinnersKeyboard() }); + return; + } + if (action.type === 'gwiz_await_custom_winners') { + if (!requireAdmin(ctx)) return; + const winners = Number(text); + if (!Number.isInteger(winners) || winners <= 0) { await ctx.reply('Please enter a valid whole number.'); return; } + action.data.maxWinners = winners; + user.pendingAction = { type: 'gwiz_step_sc', data: action.data }; + await ctx.reply('*Step 3:* SC per winner?', { parse_mode: 'Markdown', ...gwizScKeyboard() }); + return; + } + if (action.type === 'gwiz_await_custom_sc') { + if (!requireAdmin(ctx)) return; + const sc = Number(text); + if (Number.isNaN(sc) || sc <= 0) { await ctx.reply('Please enter a valid SC amount.'); return; } + action.data.scPerWinner = sc; + user.pendingAction = { type: 'gwiz_step_duration', data: action.data }; + await ctx.reply('*Step 4:* Countdown duration?', { parse_mode: 'Markdown', ...gwizDurationKeyboard() }); + return; + } + if (action.type === 'gwiz_await_custom_duration') { + if (!requireAdmin(ctx)) return; + const mins = Number(text); + if (Number.isNaN(mins) || mins <= 0) { await ctx.reply('Please enter a valid duration in minutes.'); return; } + action.data.durationMinutes = mins; + user.pendingAction = { type: 'gwiz_step_minparts', data: action.data }; + await ctx.reply('*Step 5:* Minimum participants?', { parse_mode: 'Markdown', ...gwizMinPartsKeyboard() }); + return; + } + if (action.type === 'gwiz_await_custom_minparts') { + if (!requireAdmin(ctx)) return; + const minP = Number(text); + if (Number.isNaN(minP) || minP < 0) { await ctx.reply('Please enter a valid number (0 or more).'); return; } + action.data.minParticipants = minP; + user.pendingAction = { type: 'gwiz_step_surface', data: action.data }; + await ctx.reply( + '*Step 6:* Where can users join?', + { parse_mode: 'Markdown', ...gwizSurfaceKeyboard(action.data.joinSurface) }, + ); + return; + } + + // Giveaway creation wizard (legacy text-based flow) if (action.type === 'gw_winners') { const winners = Number(text); if (!Number.isInteger(winners) || winners <= 0) return ctx.reply('Please enter a valid winners count.'); @@ -3963,6 +4462,201 @@ bot.action('gw_create_no', async (ctx) => { await ctx.reply('Giveaway setup cancelled.'); }); +// ========================= +// Giveaway Wizard action handlers +// ========================= + +bot.action('gwiz_cancel', async (ctx) => { + const user = getUser(ctx); + clearPendingAction(user); + await ctx.answerCbQuery('Cancelled'); + await ctx.reply('Giveaway wizard cancelled.'); +}); + +// ── Start wizard from group inline button ── +bot.action('gwiz_start_here', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + const user = getUser(ctx); + await gwizStart(ctx, user, { + chatId: ctx.chat.id, + chatTitle: ctx.chat.title || 'Group', + startedBy: ctx.from.id, + }); +}); + +// ── Title step ────────────────────────────────────────────────────────────── +bot.action('gwiz_title_skip', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + if (!user.pendingAction || user.pendingAction.type !== 'gwiz_await_title') { await ctx.answerCbQuery(); return; } + user.pendingAction.data.title = ''; + user.pendingAction = { type: 'gwiz_step_winners', data: user.pendingAction.data }; + await ctx.answerCbQuery(); + await ctx.reply('*Step 2:* How many winners?', { parse_mode: 'Markdown', ...gwizWinnersKeyboard() }); +}); + +// ── Winners step ──────────────────────────────────────────────────────────── +async function gwizSetWinners(ctx, count) { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + if (!user.pendingAction || !['gwiz_step_winners', 'gwiz_await_title'].includes(user.pendingAction.type)) { await ctx.answerCbQuery(); return; } + user.pendingAction.data.maxWinners = count; + user.pendingAction = { type: 'gwiz_step_sc', data: user.pendingAction.data }; + await ctx.answerCbQuery(); + await ctx.reply('*Step 3:* SC per winner?', { parse_mode: 'Markdown', ...gwizScKeyboard() }); +} + +bot.action('gwiz_winners_1', (ctx) => gwizSetWinners(ctx, 1)); +bot.action('gwiz_winners_2', (ctx) => gwizSetWinners(ctx, 2)); +bot.action('gwiz_winners_3', (ctx) => gwizSetWinners(ctx, 3)); +bot.action('gwiz_winners_5', (ctx) => gwizSetWinners(ctx, 5)); +bot.action('gwiz_winners_custom', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + if (!user.pendingAction) { await ctx.answerCbQuery(); return; } + user.pendingAction = { type: 'gwiz_await_custom_winners', data: user.pendingAction.data }; + await ctx.answerCbQuery(); + await ctx.reply('Type the number of winners:'); +}); + +// ── SC per winner step ────────────────────────────────────────────────────── +async function gwizSetSc(ctx, amount) { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_sc') { await ctx.answerCbQuery(); return; } + user.pendingAction.data.scPerWinner = amount; + user.pendingAction = { type: 'gwiz_step_duration', data: user.pendingAction.data }; + await ctx.answerCbQuery(); + await ctx.reply('*Step 4:* Countdown duration?', { parse_mode: 'Markdown', ...gwizDurationKeyboard() }); +} + +bot.action('gwiz_sc_5', (ctx) => gwizSetSc(ctx, 5)); +bot.action('gwiz_sc_10', (ctx) => gwizSetSc(ctx, 10)); +bot.action('gwiz_sc_25', (ctx) => gwizSetSc(ctx, 25)); +bot.action('gwiz_sc_50', (ctx) => gwizSetSc(ctx, 50)); +bot.action('gwiz_sc_custom', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + if (!user.pendingAction) { await ctx.answerCbQuery(); return; } + user.pendingAction = { type: 'gwiz_await_custom_sc', data: user.pendingAction.data }; + await ctx.answerCbQuery(); + await ctx.reply('Type the SC amount per winner:'); +}); + +// ── Duration step ─────────────────────────────────────────────────────────── +async function gwizSetDuration(ctx, minutes) { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_duration') { await ctx.answerCbQuery(); return; } + user.pendingAction.data.durationMinutes = minutes; + user.pendingAction = { type: 'gwiz_step_minparts', data: user.pendingAction.data }; + await ctx.answerCbQuery(); + await ctx.reply('*Step 5:* Minimum participants? (giveaway will wait if not met)', { parse_mode: 'Markdown', ...gwizMinPartsKeyboard() }); +} + +bot.action('gwiz_dur_5', (ctx) => gwizSetDuration(ctx, 5)); +bot.action('gwiz_dur_15', (ctx) => gwizSetDuration(ctx, 15)); +bot.action('gwiz_dur_30', (ctx) => gwizSetDuration(ctx, 30)); +bot.action('gwiz_dur_60', (ctx) => gwizSetDuration(ctx, 60)); +bot.action('gwiz_dur_120', (ctx) => gwizSetDuration(ctx, 120)); +bot.action('gwiz_dur_240', (ctx) => gwizSetDuration(ctx, 240)); +bot.action('gwiz_dur_custom', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + if (!user.pendingAction) { await ctx.answerCbQuery(); return; } + user.pendingAction = { type: 'gwiz_await_custom_duration', data: user.pendingAction.data }; + await ctx.answerCbQuery(); + await ctx.reply('Type the duration in minutes:'); +}); + +// ── Min participants step ─────────────────────────────────────────────────── +async function gwizSetMinParts(ctx, count) { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_minparts') { await ctx.answerCbQuery(); return; } + user.pendingAction.data.minParticipants = count; + user.pendingAction = { type: 'gwiz_step_surface', data: user.pendingAction.data }; + await ctx.answerCbQuery(); + await ctx.reply( + '*Step 6:* Where can users join?\n\n📣 *Group* — join button in group chat only\n💬 *DM* — join button in bot DM only\n🌐 *Both* — group button + DM broadcast', + { parse_mode: 'Markdown', ...gwizSurfaceKeyboard(user.pendingAction.data.joinSurface) }, + ); +} + +bot.action('gwiz_minp_0', (ctx) => gwizSetMinParts(ctx, 0)); +bot.action('gwiz_minp_5', (ctx) => gwizSetMinParts(ctx, 5)); +bot.action('gwiz_minp_10', (ctx) => gwizSetMinParts(ctx, 10)); +bot.action('gwiz_minp_20', (ctx) => gwizSetMinParts(ctx, 20)); +bot.action('gwiz_minp_custom', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + if (!user.pendingAction) { await ctx.answerCbQuery(); return; } + user.pendingAction = { type: 'gwiz_await_custom_minparts', data: user.pendingAction.data }; + await ctx.answerCbQuery(); + await ctx.reply('Type the minimum number of participants (0 = no minimum):'); +}); + +// ── Join surface step ─────────────────────────────────────────────────────── +async function gwizSetSurface(ctx, surface) { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_surface') { await ctx.answerCbQuery(); return; } + user.pendingAction.data.joinSurface = surface; + user.pendingAction = { type: 'gwiz_step_reqs', data: user.pendingAction.data }; + await ctx.answerCbQuery(); + await ctx.reply( + '*Step 7:* Toggle eligibility requirements (tap to toggle ON/OFF).\n\nTap ✅ Done when finished.', + { parse_mode: 'Markdown', ...gwizReqsKeyboard(user.pendingAction.data) }, + ); +} + +bot.action('gwiz_surface_group', (ctx) => gwizSetSurface(ctx, 'group')); +bot.action('gwiz_surface_dm', (ctx) => gwizSetSurface(ctx, 'dm')); +bot.action('gwiz_surface_both', (ctx) => gwizSetSurface(ctx, 'both')); + +// ── Requirements toggles ──────────────────────────────────────────────────── +async function gwizToggleReq(ctx, field) { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_reqs') { await ctx.answerCbQuery(); return; } + const d = user.pendingAction.data; + d[field] = !d[field]; + await ctx.answerCbQuery(d[field] ? 'Enabled' : 'Disabled'); + // Edit the message keyboard in-place to reflect the new toggle state + await ctx.editMessageReplyMarkup(gwizReqsKeyboard(d).reply_markup).catch(async () => { + // If edit fails (e.g. inline edit not available), send a fresh reply + await ctx.reply('Requirements updated:', gwizReqsKeyboard(d)); + }); +} + +bot.action('gwiz_req_linked', (ctx) => gwizToggleReq(ctx, 'requireLinked')); +bot.action('gwiz_req_channel', (ctx) => gwizToggleReq(ctx, 'requireChannel')); +bot.action('gwiz_req_group', (ctx) => gwizToggleReq(ctx, 'requireGroup')); +bot.action('gwiz_req_age', (ctx) => gwizToggleReq(ctx, 'requireAge')); +bot.action('gwiz_req_verified', (ctx) => gwizToggleReq(ctx, 'requireVerified')); +bot.action('gwiz_req_promo', (ctx) => gwizToggleReq(ctx, 'requirePromo')); + +// ── Confirm step ──────────────────────────────────────────────────────────── +bot.action('gwiz_reqs_done', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_reqs') { await ctx.answerCbQuery(); return; } + const d = user.pendingAction.data; + user.pendingAction = { type: 'gw_confirm', data: d }; // reuse existing confirm flow + await ctx.answerCbQuery(); + await ctx.reply( + gwizSummaryText(d), + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('🚀 Launch Giveaway!', 'gw_create_yes')], + [Markup.button.callback('❌ Cancel', 'gw_create_no')], + ]), + }, + ); +}); + bot.action('gw_create_yes', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); @@ -3974,10 +4668,12 @@ bot.action('gw_create_yes', async (ctx) => { user.pendingAction = null; const giveaway = createGiveaway(config); giveawayStore.running.set(giveaway.id, giveaway); - await ctx.answerCbQuery('Giveaway started'); - await announceGiveaway(giveaway); + await ctx.answerCbQuery('Giveaway started!'); + const botUsername = (ctx.botInfo && ctx.botInfo.username) || ''; + await announceGiveaway(giveaway, botUsername); scheduleGiveawayReminders(giveaway); resetGiveawayTimer(giveaway); + logEvent('info', 'Giveaway created', { id: giveaway.id, by: ctx.from.id, surface: giveaway.joinSurface }); }); // ========================= @@ -4111,6 +4807,10 @@ function createGiveaway(config) { durationMinutes: config.testMode ? 1 : config.durationMinutes, maxWinners: config.maxWinners, scPerWinner: config.scPerWinner, + minParticipants: config.minParticipants || 0, + title: config.title || '', + joinSurface: config.joinSurface || 'group', + pinnedMsgId: null, requireChannel: config.requireChannel, requireGroup: config.requireGroup, requireLinked: config.requireLinked, @@ -4127,24 +4827,85 @@ function createGiveaway(config) { reminders: [], endTimer: null, paidOut: false, + extendedCount: 0, }; } -async function announceGiveaway(giveaway) { - const text = `🎉 SC Giveaway Started!\n• Giveaway ID: ${giveaway.id}\n• Winners: ${giveaway.maxWinners}\n• Prize: ${giveaway.scPerWinner} SC each\n• Duration: ${giveaway.durationMinutes} minutes\n• Requirements:\n - Linked Runewager username: ${giveaway.requireLinked ? 'yes' : 'no'}\n - Joined GambleCodez channel: ${giveaway.requireChannel ? 'yes' : 'no'}\n - Joined GambleCodez group: ${giveaway.requireGroup ? 'yes' : 'no'}\n - Age confirmed: ${giveaway.requireAge ? 'yes' : 'no'}\n - Account confirmed: ${giveaway.requireVerified ? 'yes' : 'no'}\n - Claimed promo: ${giveaway.requirePromo ? 'yes' : 'no'}\n - Full walkthrough: ${giveaway.requireWalkthrough ? 'yes' : 'no'}\n\nTap below to join before countdown ends.`; - await bot.telegram.sendMessage( - giveaway.chatId, - text, - Markup.inlineKeyboard([ - [Markup.button.callback('✅ Join Giveaway', `gw_join_${giveaway.id}`)], - [Markup.button.callback('📋 View Giveaway Details', `gw_details_${giveaway.id}`)], - [Markup.button.callback('🧪 My Eligibility Status', `gw_elig_${giveaway.id}`)], - [Markup.button.callback('⛔ Cancel (Admin)', `gw_cancel_${giveaway.id}`)], - [Markup.button.callback('⏱ Extend (Admin)', `gw_extend_${giveaway.id}`)], - [Markup.button.callback('👥 Edit Winners (Admin)', `gw_edit_winners_${giveaway.id}`)], - [Markup.button.callback('💠 Edit SC (Admin)', `gw_edit_sc_${giveaway.id}`)], - ]), - ); +async function announceGiveaway(giveaway, botUsername) { + const titleLine = giveaway.title ? `\n📌 *${giveaway.title}*\n` : ''; + const minPartLine = giveaway.minParticipants > 0 + ? `• Min participants: ${giveaway.minParticipants}\n` + : ''; + const reqLines = [ + giveaway.requireLinked ? '• Linked Runewager username ✅' : null, + giveaway.requireChannel ? '• Joined GambleCodez channel ✅' : null, + giveaway.requireGroup ? '• Joined GambleCodez group ✅' : null, + giveaway.requireAge ? '• Age confirmed (18+) ✅' : null, + giveaway.requireVerified ? '• Account confirmed ✅' : null, + giveaway.requirePromo ? '• Claimed promo ✅' : null, + giveaway.requireWalkthrough ? '• Full walkthrough ✅' : null, + ].filter(Boolean); + const reqBlock = reqLines.length > 0 ? `\n*Requirements:*\n${reqLines.join('\n')}\n` : ''; + + const text = [ + `🎉 *SC Giveaway Started!*${titleLine}`, + `🏆 Winners: ${giveaway.maxWinners}`, + `💰 Prize: ${giveaway.scPerWinner} SC each`, + `⏱ Duration: ${giveaway.durationMinutes} min`, + minPartLine.trim() ? minPartLine.trim() : null, + reqBlock.trim() ? reqBlock.trim() : null, + `\n👇 Tap below to join before countdown ends!`, + ].filter(Boolean).join('\n'); + + // Build join button row — depends on joinSurface + const joinRows = []; + if (giveaway.joinSurface === 'group' || giveaway.joinSurface === 'both') { + joinRows.push(Markup.button.callback('✅ Join Here', `gw_join_${giveaway.id}`)); + } + if ((giveaway.joinSurface === 'dm' || giveaway.joinSurface === 'both') && botUsername) { + joinRows.push(Markup.button.url('💬 Join via DM', `https://t.me/${botUsername}?start=join_gw_${giveaway.id}`)); + } + + const kb = Markup.inlineKeyboard([ + joinRows.length > 0 ? joinRows : [Markup.button.callback('✅ Join Giveaway', `gw_join_${giveaway.id}`)], + [ + Markup.button.callback('📋 Details', `gw_details_${giveaway.id}`), + Markup.button.callback('🧪 My Eligibility', `gw_elig_${giveaway.id}`), + ], + [ + Markup.button.callback('⛔ Cancel (Admin)', `gw_cancel_${giveaway.id}`), + Markup.button.callback('⏱ Extend (Admin)', `gw_extend_${giveaway.id}`), + ], + [ + Markup.button.callback('👥 Edit Winners (Admin)', `gw_edit_winners_${giveaway.id}`), + Markup.button.callback('💠 Edit SC (Admin)', `gw_edit_sc_${giveaway.id}`), + ], + ]); + + const sent = await bot.telegram.sendMessage(giveaway.chatId, text, { parse_mode: 'Markdown', ...kb }); + + // Attempt to pin the announcement in the group; ignore permission errors + if (sent && (giveaway.joinSurface === 'group' || giveaway.joinSurface === 'both')) { + try { + await bot.telegram.pinChatMessage(giveaway.chatId, sent.message_id, { disable_notification: true }); + giveaway.pinnedMsgId = sent.message_id; + } catch (_) { /* no pin permission — not fatal */ } + } + + // If DM surface, also send a DM join announcement to all opted-in users + if (giveaway.joinSurface === 'dm' || giveaway.joinSurface === 'both') { + const dmText = `${text}\n\n_Join directly here in DM:_`; + const dmKb = Markup.inlineKeyboard([ + [Markup.button.callback('✅ Join This Giveaway', `gw_join_${giveaway.id}`)], + ]); + for (const [, u] of userStore) { + if (u.optOutBroadcasts || u.muted) continue; + try { + // eslint-disable-next-line no-await-in-loop + await bot.telegram.sendMessage(u.id, dmText, { parse_mode: 'Markdown', ...dmKb }); + } catch (_) { /* blocked users — skip */ } + } + } } function evaluateEligibility(user, giveaway) { @@ -4198,19 +4959,52 @@ function scheduleGiveawayReminders(giveaway) { }); } -async function finalizeGiveaway(gwId) { +async function finalizeGiveaway(gwId, forceEnd = false) { const giveaway = giveawayStore.running.get(gwId); if (!giveaway) return; + + const participants = Array.from(giveaway.participants.values()); + const eligibleCount = participants.length; + + // ── Minimum participants check ──────────────────────────────────────────── + // If not enough participants and not yet extended more than twice, ask admin + if (!forceEnd && giveaway.minParticipants > 0 && eligibleCount < giveaway.minParticipants) { + const extendedCount = giveaway.extendedCount || 0; + if (extendedCount < 2) { + // Notify admins with extend/force-end options + for (const adminId of ADMIN_IDS) { + bot.telegram.sendMessage( + adminId, + `⚠️ *Giveaway #${giveaway.id}* ended with only *${eligibleCount}/${giveaway.minParticipants}* participants (min not met).\n\nOptions:`, + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback(`⏱ Extend 15 min`, `gw_auto_extend_${gwId}`), Markup.button.callback(`⛔ End Anyway`, `gw_force_end_${gwId}`)], + ]), + }, + ).catch(() => {}); + } + return; // Wait for admin decision + } + // After 2 auto-extensions: end anyway with a warning + if (!giveaway.dryRun) { + await bot.telegram.sendMessage(giveaway.chatId, `⚠️ Giveaway #${giveaway.id} ended with ${eligibleCount} participant(s) — minimum not met. Drawing from available entries.`).catch(() => {}); + } + } + giveaway.status = 'ended'; giveawayStore.running.delete(gwId); giveawayStore.history.set(gwId, giveaway); giveaway.reminders.forEach((t) => clearTimeout(t)); - const participants = Array.from(giveaway.participants.values()); - const eligibleCount = participants.length; + + // Unpin the announcement if we pinned it + if (giveaway.pinnedMsgId) { + bot.telegram.unpinChatMessage(giveaway.chatId, giveaway.pinnedMsgId).catch(() => {}); + } if (eligibleCount === 0) { - if (!giveaway.dryRun) await bot.telegram.sendMessage(giveaway.chatId, 'Giveaway ended. No eligible participants.'); + if (!giveaway.dryRun) await bot.telegram.sendMessage(giveaway.chatId, `🎉 Giveaway #${giveaway.id} ended. No eligible participants.`).catch(() => {}); await notifyAdmins(`📊 Giveaway Report #${giveaway.id}\nChat: ${giveaway.chatId}\nParticipants: 0\nSC each: ${giveaway.scPerWinner}`); return; } @@ -4218,7 +5012,7 @@ async function finalizeGiveaway(gwId) { giveaway.winners = pickRandomUnique(participants, Math.min(giveaway.maxWinners, eligibleCount)); if (!giveaway.dryRun) { - await bot.telegram.sendMessage(giveaway.chatId, renderWinnersText(giveaway)); + await bot.telegram.sendMessage(giveaway.chatId, renderWinnersText(giveaway)).catch(() => {}); } for (const winner of giveaway.winners) { @@ -4228,8 +5022,8 @@ async function finalizeGiveaway(gwId) { winner.userId, `🎉 You won Giveaway #${giveaway.id}!\nPrize: ${giveaway.scPerWinner} SC\nRunewager username on file: ${winner.runewagerUsername}\nAdmin will handle prize distribution.`, ); - } catch (e) { - // ignore DM failures + } catch (_) { + // ignore DM failures (user may have blocked bot) } } From 2176317a8abcb5630c879a14e9dc156079a43fec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 16:15:03 +0000 Subject: [PATCH 2/2] feat: persistent menus, giveaway wizard v2, referral boost, bot-only leaderboards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit USER MAIN MENU (persistent): - Auto-deleting greeting (15s) on /start with first name - sendPersistentUserMenu: delete old + send new at bottom - 5 buttons: Play Now, Claim Bonus, My Profile, Giveaways, Help/Commands - Tooltips in button text; reappears after every major action - pmenu_* action handlers for all 5 buttons + pmenu_admin ADMIN MAIN MENU (persistent): - sendPersistentAdminMenu: delete old + send new at bottom - 6 buttons: System Status, Stats Dashboard, Start Giveaway, Active Giveaways, Tools, Back to User Menu - pamenu_* action handlers for all admin menu items - Admin Tools: force refresh menus, clear stuck flows, health check, show logs - Active Giveaways panel with per-giveaway End/Extend/Cancel/Participants controls SYSTEM REQUIREMENTS (new user constraints): - Removed /spin command (cosmetic, no longer supported) - Bot-only leaderboards: /leaderboard = top referrers, /leaderboard_weekly = most active (commands used) - No Runewager API, no Discord API, no external verification - commandsUsed counter on every getUser() call for activity tracking REFERRAL BOOST SYSTEM: - awardReferralProgress() now sets boostExpiresAt = now + 7 days on referrer - referralCount, lastReferralAt, boostExpiresAt fields in user schema - finalizeGiveaway: winners with active boost receive 2× SC (winner.awardedSc = base * 2) - Admin report shows boosted winners (🔥 boosted) - Stats text shows referrals in window + active boosted users count GIVEAWAY WIZARD (9 steps, reordered per spec): - Step 1: Name/Description (skip → default) - Step 2: SC Per Winner (was step 3) - Step 3: Number of Winners (was step 2) - Step 4: Countdown Duration - Step 5: Minimum Participants - Step 6: Join Targets — separate Group/DM toggle buttons (joinGroup, joinDm) - Step 7: Referral + Bonus Info (auto-include, informational proceed) - Step 8: Confirmation summary - Step 9: Launch/Post - gwizSummaryText updated to show joinGroup/joinDm surfaces and referral boost note - Surface stored as joinGroup+joinDm booleans; joinSurface computed at launch WIRING: - to_main_menu, menu command, menu_page_1 → sendPersistentUserMenu - open_admin_dashboard, admin_dashboard_action, admin_dash_page_1 → sendPersistentAdminMenu - /admin command in DM → sendPersistentAdminMenu - promo_confirm_claimed → sendPersistentUserMenu after completion - onboard_gcz_continue, sendGambleCodezVIPStep(sawGambleCodezStep) → sendPersistentUserMenu https://claude.ai/code/session_01X3PxGFF5zzKptQwVkjYzzN --- index.js | 911 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 744 insertions(+), 167 deletions(-) diff --git a/index.js b/index.js index 11bc1ee..8bcd5f8 100644 --- a/index.js +++ b/index.js @@ -438,6 +438,15 @@ function createDefaultUser(user) { tooltipsEnabled: false, // show tooltip hints in help booklet }, lastSeenAt: Date.now(), + mainMenuMsgId: null, + mainMenuChatId: null, + adminMenuMsgId: null, + adminMenuChatId: null, + referralCount: 0, + lastReferralAt: 0, + boostExpiresAt: 0, // ms timestamp; if > now, winner gets 2× SC + commandsUsed: 0, + sessionsCount: 0, wagerBonus: { attempts: 0, // total requests submitted; max 3 status: 'none', // none | pending | approved | denied | bonus_sent @@ -459,6 +468,7 @@ function getUser(ctx) { user.firstName = ctx.from.first_name || user.firstName; user.interactedAtLeastOnce = true; user.lastSeenAt = Date.now(); + user.commandsUsed = (user.commandsUsed || 0) + 1; if (!user.wagerBonus) { user.wagerBonus = { attempts: 0, @@ -1041,6 +1051,216 @@ async function sendMainMenu(ctx, user, page = 1) { ); } +// ── Persistent Menu System ────────────────────────────────────────────────── + +/** Keyboard for the new persistent USER MAIN MENU */ +function userMainMenuKeyboard(isAdminUser) { + const rows = [ + [ + Markup.button.url('🎮 Play Now', LINKS.miniAppPlay), + Markup.button.callback('🎁 Claim Bonus', 'pmenu_claim_bonus'), + ], + [ + Markup.button.callback('📊 My Profile', 'pmenu_my_profile'), + Markup.button.callback('🏆 Giveaways', 'pmenu_giveaways'), + ], + [Markup.button.callback('❓ Help / Commands', 'pmenu_help')], + ]; + if (isAdminUser) { + rows.push([Markup.button.callback('🛠 Admin Menu', 'pmenu_admin')]); + } + return Markup.inlineKeyboard(rows); +} + +/** Text for the persistent USER MAIN MENU */ +function userMainMenuText(user) { + const name = user.firstName ? user.firstName : 'there'; + const statusLine = buildUserStatusLine(user); + return ( + `👋 Hey ${name}! Here's your Runewager menu.\n\n` + + (statusLine ? `${statusLine}\n\n` : '') + + `🎮 *Play Now* — Open the Runewager Mini App\n` + + `🎁 *Claim Bonus* — New user SC bonus & 30 SC wager bonus\n` + + `📊 *My Profile* — View your linked account & stats\n` + + `🏆 *Giveaways* — Active SC giveaways\n` + + `❓ *Help / Commands* — Full command reference` + ); +} + +/** + * Send (or replace) the persistent user main menu at the bottom of the chat. + * Deletes the previous pinned menu message, then sends a fresh one. + */ +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; + const isAdminUser = ADMIN_IDS.includes(Number(user.id)); + + // Delete previous persistent menu message if it exists + if (user.mainMenuMsgId && user.mainMenuChatId) { + try { + await ctx.telegram.deleteMessage(user.mainMenuChatId, user.mainMenuMsgId); + } catch (_) { /* ignore — message may have been deleted already */ } + user.mainMenuMsgId = null; + user.mainMenuChatId = null; + } + + const text = userMainMenuText(user); + const keyboard = userMainMenuKeyboard(isAdminUser); + const sent = await ctx.telegram.sendMessage(chatId, text, { parse_mode: 'Markdown', ...keyboard }); + user.mainMenuMsgId = sent.message_id; + user.mainMenuChatId = sent.chat.id; +} + +/** Keyboard for the persistent ADMIN MAIN MENU */ +function adminMainMenuKeyboard() { + return Markup.inlineKeyboard([ + [Markup.button.callback('📟 System Status', 'pamenu_status')], + [Markup.button.callback('📈 Stats Dashboard', 'pamenu_stats')], + [Markup.button.callback('🎉 Start Giveaway', 'pamenu_start_giveaway')], + [Markup.button.callback('🎁 Active Giveaways', 'pamenu_active_giveaways')], + [Markup.button.callback('🛠 Tools', 'pamenu_tools')], + [Markup.button.callback('↩ Back to User Menu', 'pamenu_back_user')], + ]); +} + +/** Header text for the persistent ADMIN MAIN MENU */ +function adminMainMenuText() { + const dash = buildDashboard(); + const uptimeSec = Math.floor(process.uptime()); + const uptimeH = Math.floor(uptimeSec / 3600); + const uptimeM = Math.floor((uptimeSec % 3600) / 60); + const uptimeStr = uptimeH > 0 ? `${uptimeH}h ${uptimeM}m` : `${uptimeM}m`; + const lastPersistAge = lastPersistAt ? Math.floor((Date.now() - lastPersistAt) / 1000) : 99999; + const statusLight = lastPersistAge < 300 ? '🟢' : lastPersistAge < 600 ? '🟡' : '🔴'; + return ( + `🛠 *Admin Menu*\n\n` + + `${statusLight} Bot: Uptime ${uptimeStr} · Users: ${dash.totalUsers} · Giveaways: ${dash.runningGiveaways} running` + ); +} + +/** + * Send (or replace) the persistent admin main menu. + * Deletes the previous admin menu message, then sends a fresh one. + */ +async function sendPersistentAdminMenu(ctx, user) { + const chatId = ctx.chat ? ctx.chat.id : (ctx.callbackQuery && ctx.callbackQuery.message ? ctx.callbackQuery.message.chat.id : null); + if (!chatId) return; + + // Delete previous admin menu message if it exists + if (user.adminMenuMsgId && user.adminMenuChatId) { + try { + await ctx.telegram.deleteMessage(user.adminMenuChatId, user.adminMenuMsgId); + } catch (_) { /* ignore */ } + user.adminMenuMsgId = null; + user.adminMenuChatId = null; + } + + const text = adminMainMenuText(); + const keyboard = adminMainMenuKeyboard(); + const sent = await ctx.telegram.sendMessage(chatId, text, { parse_mode: 'Markdown', ...keyboard }); + user.adminMenuMsgId = sent.message_id; + user.adminMenuChatId = sent.chat.id; +} + +/** System status panel text with status lights and last error logs */ +function buildSystemStatusText() { + const uptimeSec = Math.floor(process.uptime()); + const uptimeH = Math.floor(uptimeSec / 3600); + const uptimeM = Math.floor((uptimeSec % 3600) / 60); + const uptimeStr = uptimeH > 0 ? `${uptimeH}h ${uptimeM}m` : `${uptimeM}m`; + + const lastPersistAge = lastPersistAt ? Math.floor((Date.now() - lastPersistAt) / 1000) : 99999; + const botLight = lastPersistAge < 300 ? '🟢' : lastPersistAge < 600 ? '🟡' : '🔴'; + const botStatus = lastPersistAge < 300 ? 'OK' : lastPersistAge < 600 ? 'Degraded' : 'Offline'; + + // Deploy time + let deployTime = process.env.DEPLOY_TIME || ''; + if (!deployTime) { + try { + const releaseFile = path.join(PROJECT_DIR, '.release_info'); + if (fs.existsSync(releaseFile)) { + const releaseRaw = JSON.parse(fs.readFileSync(releaseFile, 'utf8')); + deployTime = releaseRaw.deployedAt || releaseRaw.timestamp || ''; + } + } catch (_) { /* optional */ } + } + + // Last 10 error log entries + const errorEvents = analyticsStore.events + .filter((e) => e.event === 'error') + .slice(-10) + .map((e) => ` • [${new Date(e.ts).toISOString().slice(11, 19)}] ${e.data && e.data.message ? e.data.message : JSON.stringify(e.data)}`) + .join('\n'); + + const lines = [ + `📟 *System Status*`, + ``, + `${botLight} Bot: ${botStatus}`, + ` Uptime: ${uptimeStr}`, + deployTime ? ` Deployed: ${deployTime}` : null, + ` Node: ${process.version}`, + ` Version: ${pkgVersion}`, + ``, + `🌐 Telegram API: (live polling active)`, + `🎮 Mini App: ${LINKS.miniAppPlay ? 'configured' : 'not set'}`, + ``, + `⚠️ Last 10 Errors:`, + errorEvents || ` (none recorded)`, + ].filter((l) => l !== null).join('\n'); + return lines; +} + +/** Build text listing active giveaways for admin */ +function buildActiveGiveawaysText() { + const running = Array.from(giveawayStore.running.values()); + if (running.length === 0) return '🎁 *Active Giveaways*\n\nNo giveaways are currently running.'; + const lines = [`🎁 *Active Giveaways* (${running.length} running)\n`]; + for (const gw of running) { + const remainSec = Math.max(0, Math.floor((gw.endsAt - Date.now()) / 1000)); + const remainStr = remainSec > 3600 + ? `${Math.floor(remainSec / 3600)}h ${Math.floor((remainSec % 3600) / 60)}m` + : `${Math.floor(remainSec / 60)}m ${remainSec % 60}s`; + lines.push( + `*#${gw.id}* ${gw.title ? `— ${gw.title}` : ''}\n` + + ` 💰 ${gw.scPerWinner} SC × ${gw.winnersCount} winner(s)\n` + + ` 👥 ${gw.participants.size} participant(s)${gw.minParticipants ? ` (min: ${gw.minParticipants})` : ''}\n` + + ` ⏱ Time left: ${remainStr}\n` + + ` 📡 Surface: ${gw.joinSurface || 'group'}`, + ); + } + return lines.join('\n'); +} + +/** Inline keyboard for admin active giveaways panel */ +function activeGiveawaysKeyboard(giveaways) { + const rows = []; + for (const gw of giveaways) { + rows.push([ + Markup.button.callback(`#${gw.id} End Early`, `pamenu_gw_end_${gw.id}`), + Markup.button.callback(`#${gw.id} Extend`, `pamenu_gw_extend_${gw.id}`), + ]); + rows.push([ + Markup.button.callback(`#${gw.id} Cancel`, `pamenu_gw_cancel_${gw.id}`), + Markup.button.callback(`#${gw.id} Participants`, `pamenu_gw_participants_${gw.id}`), + ]); + } + rows.push([Markup.button.callback('↩ Admin Menu', 'pamenu_back_admin')]); + return Markup.inlineKeyboard(rows); +} + +/** Keyboard for the admin Tools menu */ +function toolsMenuKeyboard() { + return Markup.inlineKeyboard([ + [Markup.button.callback('🔄 Force Refresh Menus', 'pamenu_tools_refresh')], + [Markup.button.callback('🧹 Clear Stuck Flows', 'pamenu_tools_clear_flows')], + [Markup.button.callback('🩺 Re-run Health Check', 'pamenu_tools_health')], + [Markup.button.callback('📋 Show Logs', 'pamenu_tools_logs')], + [Markup.button.callback('🛠 Run TestAll', 'admin_cmd_testall')], + [Markup.button.callback('↩ Admin Menu', 'pamenu_back_admin')], + ]); +} + function buildUserStatusLine(user) { const steps = []; if (user.ageConfirmed) steps.push('18+ ✅'); @@ -1156,6 +1376,15 @@ function awardReferralProgress(userId, base = 1) { const points = base * mult; referralStore.stats.set(userId, (referralStore.stats.get(userId) || 0) + points); referralStore.weekly.set(userId, (referralStore.weekly.get(userId) || 0) + points); + + // Apply 7-day SC giveaway boost (2×) to the referrer + const referrer = userStore.get(userId); + if (referrer) { + referrer.referralCount = (referrer.referralCount || 0) + 1; + referrer.lastReferralAt = Date.now(); + // Reset or extend boost window by 7 days from now + referrer.boostExpiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; + } } function trackAnalytics(event, payload = {}) { @@ -1358,8 +1587,7 @@ async function configureBotSurface() { { command: 'referral', description: 'Get your referral link' }, { command: 'walkthrough', description: 'Guided walkthrough (35 steps)' }, { command: 'linkrunewager', description: 'Link your Runewager username' }, - { command: 'leaderboard', description: 'Referral leaderboard' }, - { command: 'spin', description: 'Daily bonus wheel' }, + { command: 'leaderboard', description: 'Bot activity leaderboard' }, { command: 'bonus', description: 'View 30 SC bonus status / request bonus' }, { command: 'bugreport', description: 'Report a bug or issue' }, { command: 'commands', description: 'Help guide (alias for /help)' }, @@ -1376,17 +1604,15 @@ async function configureBotSurface() { { command: 'start', description: 'Start / link your account (opens in DM)' }, { command: 'signup', description: 'Sign up under GambleCodez affiliate' }, { command: 'promo', description: 'View current promo code and bonus' }, - { command: 'leaderboard', description: 'Global referral leaderboard' }, - { command: 'leaderboard_weekly', description: 'Weekly referral leaderboard' }, + { command: 'leaderboard', description: 'Bot activity leaderboard' }, { command: 'referral', description: 'Get your referral link' }, { command: 'status', description: 'Quick account status' }, { command: 'profile', description: 'View your profile' }, - { command: 'bonus', description: 'View 30 SC wager bonus' }, + { command: 'bonus', description: 'View 30 SC bonus status' }, { command: 'linkrunewager', description: 'Link your Runewager username' }, { command: 'discord', description: 'Open Runewager Discord' }, { command: 'help', description: 'Help and commands' }, { command: 'giveaway', description: 'View / join active giveaways' }, - { command: 'spin', description: 'Daily bonus wheel' }, { command: 'affiliate', description: 'Open affiliate / promo entry page' }, { command: 'bugreport', description: 'Report a bug or issue' }, ]; @@ -1524,10 +1750,29 @@ bot.start(safeStepHandler('start', async (ctx) => { await ctx.reply(COPY.ageGate, ageGateKeyboard()); return; } + + // ── Auto-deleting greeting (15 seconds) ──────────────────────────────────── + const firstName = user.firstName || ctx.from.first_name || 'there'; + const greetingMsg = await ctx.reply( + `Welcome, ${firstName}! 👋 I'm your Runewager assistant — let's get you set up.`, + ); + const greetChatId = greetingMsg.chat.id; + const greetMsgId = greetingMsg.message_id; + setTimeout(async () => { + try { await ctx.telegram.deleteMessage(greetChatId, greetMsgId); } catch (_) { /* ignore */ } + }, 15000); + if (user.onboarding.currentStep < 5) { - await ctx.reply(`👋 Welcome back! Your next step: ${onboardingStepLabel(user.onboarding.currentStep)}.`); + const stepMsg = await ctx.reply(`⏳ Your next step: ${onboardingStepLabel(user.onboarding.currentStep)}.`); + const sChatId = stepMsg.chat.id; + const sMsgId = stepMsg.message_id; + setTimeout(async () => { + try { await ctx.telegram.deleteMessage(sChatId, sMsgId); } catch (_) { /* ignore */ } + }, 12000); } - await sendMainMenu(ctx, user, 1); + + // ── Persistent user main menu ─────────────────────────────────────────────── + await sendPersistentUserMenu(ctx, user); })); bot.command('menu', async (ctx) => { @@ -1537,7 +1782,7 @@ bot.command('menu', async (ctx) => { await ctx.reply(COPY.ageGate, ageGateKeyboard()); return; } - await sendMainMenu(ctx, user); + await sendPersistentUserMenu(ctx, user); }); bot.command('help', async (ctx) => { @@ -1855,6 +2100,7 @@ bot.command('walkthrough', async (ctx) => { bot.command('admin', async (ctx) => { if (!requireAdmin(ctx)) return; + const user = getUser(ctx); const isGroup = ctx.chat && (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'); if (isGroup) { // In groups: send a brief inline button so admin can open dashboard in DM @@ -1864,8 +2110,8 @@ bot.command('admin', async (ctx) => { ); return; } - // In DM: send the full rich dashboard - await sendAdminDashboard(ctx, 1); + // In DM: send the persistent admin menu + await sendPersistentAdminMenu(ctx, user); }); bot.command('admin_help', async (ctx) => { @@ -1877,33 +2123,33 @@ bot.command('admin_help', async (ctx) => { // ========================= // Giveaway Wizard — Inline keyboard-driven creation flow // Triggered by /giveaway command (admin) or "Start Giveaway" button. -// Steps: title → winners → sc → duration → minParts → surface → reqs → confirm +// Steps: 1.title → 2.sc → 3.winners → 4.duration → 5.minParts → 6.surface(group+DM toggles) → 7.referralInfo → 8.confirm → 9.post // ========================= -/** Keyboard for step: number of winners */ -function gwizWinnersKeyboard() { +/** Keyboard for step: SC per winner (Step 2) */ +function gwizScKeyboard() { return Markup.inlineKeyboard([ [ - Markup.button.callback('1', 'gwiz_winners_1'), - Markup.button.callback('2', 'gwiz_winners_2'), - Markup.button.callback('3', 'gwiz_winners_3'), - Markup.button.callback('5', 'gwiz_winners_5'), + Markup.button.callback('5 SC', 'gwiz_sc_5'), + Markup.button.callback('10 SC', 'gwiz_sc_10'), + Markup.button.callback('25 SC', 'gwiz_sc_25'), + Markup.button.callback('50 SC', 'gwiz_sc_50'), ], - [Markup.button.callback('✏️ Custom (type it)', 'gwiz_winners_custom')], + [Markup.button.callback('✏️ Custom (type it)', 'gwiz_sc_custom')], [Markup.button.callback('❌ Cancel', 'gwiz_cancel')], ]); } -/** Keyboard for step: SC per winner */ -function gwizScKeyboard() { +/** Keyboard for step: number of winners (Step 3) */ +function gwizWinnersKeyboard() { return Markup.inlineKeyboard([ [ - Markup.button.callback('5 SC', 'gwiz_sc_5'), - Markup.button.callback('10 SC', 'gwiz_sc_10'), - Markup.button.callback('25 SC', 'gwiz_sc_25'), - Markup.button.callback('50 SC', 'gwiz_sc_50'), + Markup.button.callback('1', 'gwiz_winners_1'), + Markup.button.callback('2', 'gwiz_winners_2'), + Markup.button.callback('3', 'gwiz_winners_3'), + Markup.button.callback('5', 'gwiz_winners_5'), ], - [Markup.button.callback('✏️ Custom (type it)', 'gwiz_sc_custom')], + [Markup.button.callback('✏️ Custom (type it)', 'gwiz_winners_custom')], [Markup.button.callback('❌ Cancel', 'gwiz_cancel')], ]); } @@ -1940,54 +2186,47 @@ function gwizMinPartsKeyboard() { ]); } -/** Keyboard for step: join surface (where users can enter) */ -function gwizSurfaceKeyboard(selected) { - const mark = (s) => (selected === s ? '✅ ' : ''); +/** + * Keyboard for step 6: Join Targets — group and DM toggles. + * data.joinGroup and data.joinDm are booleans. + */ +function gwizSurfaceKeyboard(data) { + const chk = (v) => (v ? '✅ ' : '☐ '); return Markup.inlineKeyboard([ - [Markup.button.callback(`${mark('group')}📣 Group Only`, 'gwiz_surface_group')], - [Markup.button.callback(`${mark('dm')}💬 DM Only`, 'gwiz_surface_dm')], - [Markup.button.callback(`${mark('both')}🌐 Both (Group + DM)`, 'gwiz_surface_both')], + [Markup.button.callback(`${chk(data.joinGroup)}📣 Group Chat`, 'gwiz_surface_group')], + [Markup.button.callback(`${chk(data.joinDm)}💬 Bot DM`, 'gwiz_surface_dm')], + [Markup.button.callback('➡️ Next →', 'gwiz_surface_done')], [Markup.button.callback('❌ Cancel', 'gwiz_cancel')], ]); } -/** - * Keyboard for step: eligibility requirements (toggles). - * data holds the current wizard data including requirement booleans. - */ -function gwizReqsKeyboard(data) { - const chk = (v) => (v ? '✅ ' : '☐ '); +/** Step 7: Referral + bonus info — auto-include, just show info + proceed button */ +function gwizJoinInfoKeyboard() { return Markup.inlineKeyboard([ - [Markup.button.callback(`${chk(data.requireLinked)}Linked Username`, 'gwiz_req_linked')], - [Markup.button.callback(`${chk(data.requireChannel)}Channel Join`, 'gwiz_req_channel')], - [Markup.button.callback(`${chk(data.requireGroup)}Group Join`, 'gwiz_req_group')], - [Markup.button.callback(`${chk(data.requireAge)}Age Confirmed`, 'gwiz_req_age')], - [Markup.button.callback(`${chk(data.requireVerified)}Account Verified`, 'gwiz_req_verified')], - [Markup.button.callback(`${chk(data.requirePromo)}Promo Claimed`, 'gwiz_req_promo')], - [Markup.button.callback('✅ Done — Next Step →', 'gwiz_reqs_done')], + [Markup.button.callback('✅ Include & Continue →', 'gwiz_joininfo_done')], [Markup.button.callback('❌ Cancel', 'gwiz_cancel')], ]); } /** Show giveaway wizard confirmation summary */ function gwizSummaryText(d) { + const surfaces = []; + if (d.joinGroup) surfaces.push('📣 Group Chat'); + if (d.joinDm) surfaces.push('💬 Bot DM'); + const surfaceStr = surfaces.length ? surfaces.join(' + ') : '📣 Group Chat (default)'; return [ `🎉 *Giveaway Setup — Review*`, ``, d.title ? `📌 Title: ${d.title}` : `📌 Title: _(default)_`, + `💰 SC per winner: ${d.scPerWinner}`, `🏆 Winners: ${d.maxWinners}`, - `💰 SC each: ${d.scPerWinner}`, `⏱ Duration: ${d.durationMinutes} min`, `👥 Min participants: ${d.minParticipants || 'none'}`, - `📣 Join surface: ${d.joinSurface}`, + `📣 Join surfaces: ${surfaceStr}`, ``, - `*Requirements:*`, - ` Linked username: ${d.requireLinked ? '✅' : '☐'}`, - ` Channel join: ${d.requireChannel ? '✅' : '☐'}`, - ` Group join: ${d.requireGroup ? '✅' : '☐'}`, - ` Age (18+): ${d.requireAge ? '✅' : '☐'}`, - ` Verified account: ${d.requireVerified ? '✅' : '☐'}`, - ` Promo claimed: ${d.requirePromo ? '✅' : '☐'}`, + `*Bonus info:*`, + ` ✅ Referral boost: Users with active referral boost receive 2× SC`, + ` ✅ Info message auto-included in announcement`, ``, `Tap *Launch!* to start or *Cancel* to abort.`, ].join('\n'); @@ -2000,30 +2239,26 @@ function gwizSummaryText(d) { async function gwizStart(ctx, user, config) { clearPendingAction(user); const data = { - chatId: config.chatId, - chatTitle: config.chatTitle, - startedBy: config.startedBy, + chatId: config.chatId || (ctx.chat && ctx.chat.id), + chatTitle: config.chatTitle || (ctx.chat && ctx.chat.title) || 'DM', + startedBy: config.startedBy || ctx.from.id, title: '', - maxWinners: 1, scPerWinner: 10, + maxWinners: 1, durationMinutes: 30, minParticipants: 0, - joinSurface: 'group', + joinGroup: true, + joinDm: false, + joinSurface: 'group', // computed from joinGroup/joinDm before launch requireLinked: true, - requireChannel: false, - requireGroup: false, - requireAge: false, - requireVerified: false, - requirePromo: false, - requireWalkthrough: false, - requireRefTag: '', + requireAge: true, testMode: false, dryRun: false, _step: 'title', }; user.pendingAction = { type: 'gwiz_await_title', data }; await ctx.reply( - `🎉 *New Giveaway Wizard*\n\n*Step 1:* Enter a title or description for this giveaway (e.g. "Weekend SC Drop").\n\nOr tap Skip to use the default title.`, + `🎉 *New Giveaway Wizard*\n\n*Step 1 of 9:* Enter a title or description for this giveaway (e.g. "Weekend SC Drop").\n\nOr tap Skip to use the default title.`, { parse_mode: 'Markdown', ...Markup.inlineKeyboard([ @@ -2420,33 +2655,33 @@ bot.command('profile', safeStepHandler('profile', async (ctx) => { await ctx.reply(`Profile\nTG: @${tgName}\nRunewager: ${user.runewagerUsername || 'not linked'}\nXP: ${user.profileXP}\nOnboarding Step: ${onboardingStepLabel(user.onboarding.currentStep)}\nReferral: ${referralCode}\nBadges: ${badgeDisplay}`); })); -bot.command('spin', safeStepHandler('spin', async (ctx) => { - const user = getUser(ctx); - const rewards = ['Neon Frame', 'Crown Badge', 'XP +10', 'Degen Aura', 'Lucky Streak']; - const reward = rewards[Math.floor(Math.random() * rewards.length)]; - addBadge(user, 'daily_spinner'); - user.profileXP += 10; - await ctx.reply(`🎡 Daily Bonus Wheel (cosmetic)\nResult: ${reward}`); -})); +// /spin removed — daily spin was cosmetic-only and is no longer supported bot.command('leaderboard', safeStepHandler('leaderboard', async (ctx) => { - if (ctx.chat.type === 'private') { - await ctx.reply('Use this command in a group chat.'); - return; - } - const top = Array.from(referralStore.stats.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10); - const text = top.length ? top.map(([id, count], i) => `${i + 1}. ${id} — ${count}`).join('\n') : 'No referral data yet.'; - await ctx.reply(`Global Referral Leaderboard\n${text}`); + // Bot-only leaderboard: most referrals (bot-side tracking only) + const allUsers = Array.from(userStore.values()); + const top = allUsers + .filter((u) => u.referralCount > 0) + .sort((a, b) => (b.referralCount || 0) - (a.referralCount || 0)) + .slice(0, 10); + const lines = top.length + ? top.map((u, i) => `${i + 1}. ${u.tgUsername ? '@' + u.tgUsername : u.firstName || 'User'} — ${u.referralCount} referral(s)${u.boostExpiresAt > Date.now() ? ' 🔥' : ''}`) + : ['No referral data yet.']; + await ctx.reply(`🏆 *Referral Leaderboard* (bot-only)\n\n${lines.join('\n')}\n\n🔥 = active 2× SC giveaway boost`, { parse_mode: 'Markdown' }); })); bot.command('leaderboard_weekly', safeStepHandler('leaderboard_weekly', async (ctx) => { - if (ctx.chat.type === 'private') { - await ctx.reply('Use this command in a group chat.'); - return; - } - const top = Array.from(referralStore.weekly.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10); - const text = top.length ? top.map(([id, count], i) => `${i + 1}. ${id} — ${count}`).join('\n') : 'No weekly referral data yet.'; - await ctx.reply(`Weekly Referral Leaderboard\n${text}`); + // Bot-only leaderboard: most active users in last 7 days + const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; + const allUsers = Array.from(userStore.values()); + const top = allUsers + .filter((u) => (u.lastSeenAt || 0) > cutoff) + .sort((a, b) => (b.commandsUsed || 0) - (a.commandsUsed || 0)) + .slice(0, 10); + const lines = top.length + ? top.map((u, i) => `${i + 1}. ${u.tgUsername ? '@' + u.tgUsername : u.firstName || 'User'} — ${u.commandsUsed || 0} commands`) + : ['No active users this week.']; + await ctx.reply(`🏆 *Most Active (7 days)*\n\n${lines.join('\n')}`, { parse_mode: 'Markdown' }); })); bot.command('admin_dashboard', safeStepHandler('admin_dashboard', async (ctx) => { @@ -2674,7 +2909,322 @@ bot.command('join', async (ctx) => { bot.action('to_main_menu', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); - await sendMainMenu(ctx, user); + await sendPersistentUserMenu(ctx, user); +}); + +// ── Persistent USER MAIN MENU actions (pmenu_*) ──────────────────────────── + +bot.action('pmenu_claim_bonus', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + // Show bonus options as temporary message then reappear persistent menu + const wb = user.wagerBonus; + const canRequest30 = wb.attempts < 3 && !isWagerBonusRequestLocked(wb.status); + await ctx.reply( + '🎁 *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', + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('🎁 New User Bonus', 'menu_claim_bonus')], + ...(canRequest30 ? [[Markup.button.callback('🎯 30 SC Wager Bonus', 'w30_request_start')]] : []), + [Markup.button.callback('📊 My Bonus Status', 'w30_my_status')], + [Markup.button.callback('↩ Back to Menu', 'to_main_menu')], + ]), + }, + ); +}); + +bot.action('pmenu_my_profile', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + const statusLine = buildUserStatusLine(user); + const step = getNextOnboardingStep(user); + const lines = [ + '📊 *My Profile*', + '', + `Name: ${user.firstName || '(not set)'}`, + `Username: @${user.tgUsername || '(not set)'}`, + `Runewager: ${user.runewagerUsername || '❌ Not linked'}`, + '', + statusLine, + '', + `Onboarding: Step ${step}/5 — ${onboardingStepLabel(step)}`, + `Promo claimed: ${user.claimedPromo ? '✅' : '❌'}`, + `XP: ${user.profileXP || 0}`, + ]; + await ctx.reply(lines.join('\n'), { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('🔗 Link Runewager Username', 'menu_link_runewager')], + [Markup.button.callback('⚙️ Settings', 'menu_settings_tab')], + [Markup.button.callback('🐞 Bug Report', 'menu_bugreport')], + [Markup.button.callback('↩ Back to Menu', 'to_main_menu')], + ]), + }); +}); + +bot.action('pmenu_giveaways', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + // Delegate to existing giveaways menu handler + const running = Array.from(giveawayStore.running.values()); + if (running.length === 0) { + await ctx.reply( + '🏆 *Giveaways*\n\nNo giveaways are currently running. Check back soon!', + { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('↩ Back', 'to_main_menu')]]) }, + ); + return; + } + const lines = [`🏆 *Active Giveaways* (${running.length})\n`]; + for (const gw of running) { + const remainSec = Math.max(0, Math.floor((gw.endsAt - Date.now()) / 1000)); + const remainStr = remainSec > 3600 + ? `${Math.floor(remainSec / 3600)}h ${Math.floor((remainSec % 3600) / 60)}m` + : `${Math.floor(remainSec / 60)}m ${remainSec % 60}s`; + const joined = user.giveawayJoinedIds.has(gw.id) ? '✅ Joined' : '⬜ Not joined'; + lines.push(`*#${gw.id}*${gw.title ? ` — ${gw.title}` : ''}\n💰 ${gw.scPerWinner} SC · 👥 ${gw.participants.size} · ⏱ ${remainStr} · ${joined}`); + } + const joinButtons = running + .filter((gw) => !user.giveawayJoinedIds.has(gw.id)) + .slice(0, 3) + .map((gw) => [Markup.button.callback(`🎉 Join #${gw.id}`, `gw_join_${gw.id}`)]); + await ctx.reply(lines.join('\n'), { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([...joinButtons, [Markup.button.callback('↩ Back', 'to_main_menu')]]), + }); +}); + +bot.action('pmenu_help', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + await sendHelpMenu(ctx, user, 1); +}); + +bot.action('pmenu_admin', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + await sendPersistentAdminMenu(ctx, user); +}); + +// ── Persistent ADMIN MAIN MENU actions (pamenu_*) ───────────────────────── + +bot.action('pamenu_status', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + await ctx.reply(buildSystemStatusText(), { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([[Markup.button.callback('↩ Admin Menu', 'pamenu_back_admin')]]), + }); + await sendPersistentAdminMenu(ctx, user); +}); + +bot.action('pamenu_stats', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + await ctx.reply( + '📈 *Stats Dashboard*\n\nSelect a time window:', + { parse_mode: 'Markdown', ...Markup.inlineKeyboard([ + [ + Markup.button.callback('🕐 24h', 'pamenu_stats_24h'), + Markup.button.callback('📅 7 days', 'pamenu_stats_7d'), + ], + [ + Markup.button.callback('📆 30 days', 'pamenu_stats_30d'), + Markup.button.callback('♾️ Lifetime', 'pamenu_stats_lifetime'), + ], + [Markup.button.callback('↩ Admin Menu', 'pamenu_back_admin')], + ]) }, + ); +}); + +bot.action('pamenu_stats_24h', async (ctx) => { + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const s = buildStatsWindow(24 * 60 * 60 * 1000); + await ctx.reply(buildStatsText('Last 24 hours', 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('↩ Admin Menu', 'pamenu_back_admin')]]) }); + await sendPersistentAdminMenu(ctx, user); +}); + +bot.action('pamenu_stats_7d', async (ctx) => { + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + await ctx.reply(buildStatsText('Last 7 days', 7 * 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('↩ Admin Menu', 'pamenu_back_admin')]]) }); + await sendPersistentAdminMenu(ctx, user); +}); + +bot.action('pamenu_stats_30d', async (ctx) => { + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + await ctx.reply(buildStatsText('Last 30 days', 30 * 24 * 60 * 60 * 1000), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('↩ Admin Menu', 'pamenu_back_admin')]]) }); + await sendPersistentAdminMenu(ctx, user); +}); + +bot.action('pamenu_stats_lifetime', async (ctx) => { + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + await ctx.reply(buildStatsText('Lifetime (all time)', 0), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('↩ Admin Menu', 'pamenu_back_admin')]]) }); + await sendPersistentAdminMenu(ctx, user); +}); + +bot.action('pamenu_start_giveaway', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + await gwizStart(ctx, user, {}); +}); + +bot.action('pamenu_active_giveaways', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + const running = Array.from(giveawayStore.running.values()); + const text = buildActiveGiveawaysText(); + const keyboard = activeGiveawaysKeyboard(running); + await ctx.reply(text, { parse_mode: 'Markdown', ...keyboard }); +}); + +bot.action('pamenu_tools', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + await ctx.reply( + '🛠 *Admin Tools*\n\nSelect a tool:', + { parse_mode: 'Markdown', ...toolsMenuKeyboard() }, + ); +}); + +bot.action('pamenu_back_user', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + await sendPersistentUserMenu(ctx, user); +}); + +bot.action('pamenu_back_admin', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + await sendPersistentAdminMenu(ctx, user); +}); + +// ── Admin Tools menu actions ────────────────────────────────────────────── + +bot.action('pamenu_tools_refresh', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery('Refreshing menus...'); + if (!requireAdmin(ctx)) return; + // Force-reset menu message IDs so next call sends fresh + user.mainMenuMsgId = null; user.mainMenuChatId = null; + user.adminMenuMsgId = null; user.adminMenuChatId = null; + await ctx.reply('✅ Menu message IDs cleared. Next action will send fresh menus.'); + await sendPersistentAdminMenu(ctx, user); +}); + +bot.action('pamenu_tools_clear_flows', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery('Clearing stuck flows...'); + if (!requireAdmin(ctx)) return; + let cleared = 0; + for (const [, u] of userStore) { + if (u.pendingAction) { u.pendingAction = null; cleared++; } + } + await ctx.reply(`✅ Cleared pending actions for ${cleared} user(s).`); + await sendPersistentAdminMenu(ctx, user); +}); + +bot.action('pamenu_tools_health', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery('Running health check...'); + if (!requireAdmin(ctx)) return; + try { + const http = require('http'); + const result = await new Promise((resolve, reject) => { + const req = http.get(`http://127.0.0.1:${PORT}/health`, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => resolve(data)); + }); + req.on('error', reject); + req.setTimeout(5000, () => { req.destroy(); reject(new Error('timeout')); }); + }); + await ctx.reply(`🩺 Health Check:\n\`\`\`\n${result.slice(0, 800)}\n\`\`\``, { parse_mode: 'Markdown' }); + } catch (e) { + await ctx.reply(`❌ Health check failed: ${e.message}`); + } + await sendPersistentAdminMenu(ctx, user); +}); + +bot.action('pamenu_tools_logs', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + await ctx.reply(buildSystemStatusText(), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('↩ Admin Menu', 'pamenu_back_admin')]]) }); + await sendPersistentAdminMenu(ctx, user); +}); + +// ── Admin active giveaway controls (pamenu_gw_*) ───────────────────────── + +bot.action(/^pamenu_gw_end_(\d+)$/, async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + const gwId = Number(ctx.match[1]); + await finalizeGiveaway(gwId, true); + await ctx.reply(`✅ Giveaway #${gwId} ended early.`); + await sendPersistentAdminMenu(ctx, user); +}); + +bot.action(/^pamenu_gw_extend_(\d+)$/, async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + const gwId = Number(ctx.match[1]); + const gw = giveawayStore.running.get(gwId); + if (!gw) { await ctx.reply(`❌ Giveaway #${gwId} not found.`); return; } + gw.endsAt += 15 * 60 * 1000; // extend by 15 minutes + await ctx.reply(`✅ Giveaway #${gwId} extended by 15 minutes.`); + await sendPersistentAdminMenu(ctx, user); +}); + +bot.action(/^pamenu_gw_cancel_(\d+)$/, async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + const gwId = Number(ctx.match[1]); + const gw = giveawayStore.running.get(gwId); + if (!gw) { await ctx.reply(`❌ Giveaway #${gwId} not found.`); return; } + if (gw.timerId) clearTimeout(gw.timerId); + if (gw.pinnedMsgId && gw.chatId) { + try { await ctx.telegram.unpinChatMessage(gw.chatId, gw.pinnedMsgId); } catch (_) { /* ignore */ } + } + giveawayStore.running.delete(gwId); + await ctx.reply(`🗑 Giveaway #${gwId} cancelled. No winners selected.`); + await sendPersistentAdminMenu(ctx, user); +}); + +bot.action(/^pamenu_gw_participants_(\d+)$/, async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + if (!requireAdmin(ctx)) return; + const gwId = Number(ctx.match[1]); + const gw = giveawayStore.running.get(gwId); + if (!gw) { await ctx.reply(`❌ Giveaway #${gwId} not found.`); return; } + const parts = Array.from(gw.participants.values()); + if (parts.length === 0) { + await ctx.reply(`Giveaway #${gwId}: No participants yet.`); + } else { + const list = parts.map((p, i) => `${i + 1}. ${p.tgUsername ? '@' + p.tgUsername : p.firstName || 'User'} (${p.userId})`).join('\n'); + await ctx.reply(`👥 Giveaway #${gwId} Participants (${parts.length}):\n\n${list}`.slice(0, 4000)); + } + await sendPersistentAdminMenu(ctx, user); }); bot.action('age_yes', async (ctx) => { @@ -2690,7 +3240,7 @@ bot.action('age_yes', async (ctx) => { // Shows the mandatory GambleCodez affiliate code step async function sendGambleCodezVIPStep(ctx, user) { if (user.sawGambleCodezStep) { - await sendMainMenu(ctx, user); + await sendPersistentUserMenu(ctx, user); return; } await ctx.reply( @@ -2731,7 +3281,7 @@ bot.action('onboard_gcz_continue', async (ctx) => { [Markup.button.callback('⬅️ Main Menu', 'to_main_menu')], ]), ); - await sendMainMenu(ctx, user); + await sendPersistentUserMenu(ctx, user); }); bot.action('onboard_skip_to_link', async (ctx) => { @@ -2936,10 +3486,14 @@ bot.action('promo_confirm_claimed', async (ctx) => { trackOnboardingProgress(user); await ctx.answerCbQuery('Saved'); await ctx.reply( - 'Your promo claim intent has been saved. If you need help, contact @GambleCodez on Telegram or use the support link below.', - Markup.inlineKeyboard([[Markup.button.url('🆘 Runewager Support', LINKS.rwDiscordSupport)]]), + '✅ Promo claim intent saved! If you need help, contact @GambleCodez on Telegram or use the support link below.', + Markup.inlineKeyboard([ + [Markup.button.url('🆘 Runewager Support', LINKS.rwDiscordSupport)], + [Markup.button.callback('↩ Back to Menu', 'to_main_menu')], + ]), ); await reactToMessage(ctx, '🎁'); + await sendPersistentUserMenu(ctx, user); }); bot.action('w30_rules', async (ctx) => { @@ -3050,7 +3604,7 @@ bot.action('open_help', async (ctx) => { bot.action('menu_page_1', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); - await sendMainMenu(ctx, user, 1); + await sendPersistentUserMenu(ctx, user); }); bot.action('menu_page_2', async (ctx) => { @@ -3151,20 +3705,23 @@ bot.action('menu_bonus_status', async (ctx) => { // ── Admin dashboard navigation ───────────────────────────────────────────── bot.action('open_admin_dashboard', async (ctx) => { if (!requireAdmin(ctx)) return; + const user = getUser(ctx); await ctx.answerCbQuery(); - await sendAdminDashboard(ctx, 1); + await sendPersistentAdminMenu(ctx, user); }); bot.action('admin_dashboard_action', async (ctx) => { if (!requireAdmin(ctx)) return; + const user = getUser(ctx); await ctx.answerCbQuery(); - await sendAdminDashboard(ctx, 1); + await sendPersistentAdminMenu(ctx, user); }); bot.action('admin_dash_page_1', async (ctx) => { if (!requireAdmin(ctx)) return; + const user = getUser(ctx); await ctx.answerCbQuery(); - await sendAdminDashboard(ctx, 1); + await sendPersistentAdminMenu(ctx, user); }); bot.action('admin_dash_page_2', async (ctx) => { @@ -3207,8 +3764,9 @@ bot.action('admin_cat_support', async (ctx) => { // Inline triggers for admin dashboard quick-action buttons bot.action('admin_cmd_start_giveaway', async (ctx) => { if (!requireAdmin(ctx)) return; + const user = getUser(ctx); await ctx.answerCbQuery(); - await ctx.reply('Use /start_giveaway to launch the giveaway wizard.'); + await gwizStart(ctx, user, {}); }); bot.action('admin_cmd_giveaway_status', async (ctx) => { @@ -3321,6 +3879,10 @@ bot.action('admin_cmd_exportbugs', async (ctx) => { /** Build a formatted stats text for a given window */ function buildStatsText(label, windowMs) { const s = buildStatsWindow(windowMs); + const cutoff = windowMs > 0 ? Date.now() - windowMs : 0; + const allUsers = Array.from(userStore.values()); + const referralsInWindow = allUsers.filter((u) => (u.lastReferralAt || 0) > cutoff).length; + const activeBoosted = allUsers.filter((u) => (u.boostExpiresAt || 0) > Date.now()).length; return [ `📈 *Stats — ${label}*`, ``, @@ -3331,6 +3893,10 @@ function buildStatsText(label, windowMs) { `Bonus sent: ${s.bonusSent}`, `Giveaways created: ${s.giveawaysCreated}`, `Giveaway participants: ${s.giveawayParticipants}`, + ``, + `*Referral Data:*`, + ` Referrals in window: ${referralsInWindow}`, + ` Users with active 2× boost: ${activeBoosted}`, ].join('\n'); } @@ -4227,17 +4793,8 @@ bot.on('text', async (ctx) => { if (action.type === 'gwiz_await_title') { if (!requireAdmin(ctx)) return; action.data.title = text.slice(0, 100); - user.pendingAction = { type: 'gwiz_step_winners', data: action.data }; - await ctx.reply('*Step 2:* How many winners?', { parse_mode: 'Markdown', ...gwizWinnersKeyboard() }); - return; - } - if (action.type === 'gwiz_await_custom_winners') { - if (!requireAdmin(ctx)) return; - const winners = Number(text); - if (!Number.isInteger(winners) || winners <= 0) { await ctx.reply('Please enter a valid whole number.'); return; } - action.data.maxWinners = winners; user.pendingAction = { type: 'gwiz_step_sc', data: action.data }; - await ctx.reply('*Step 3:* SC per winner?', { parse_mode: 'Markdown', ...gwizScKeyboard() }); + await ctx.reply('*Step 2 of 9:* How many SC per winner?', { parse_mode: 'Markdown', ...gwizScKeyboard() }); return; } if (action.type === 'gwiz_await_custom_sc') { @@ -4245,8 +4802,17 @@ bot.on('text', async (ctx) => { const sc = Number(text); if (Number.isNaN(sc) || sc <= 0) { await ctx.reply('Please enter a valid SC amount.'); return; } action.data.scPerWinner = sc; + user.pendingAction = { type: 'gwiz_step_winners', data: action.data }; + await ctx.reply('*Step 3 of 9:* How many winners?', { parse_mode: 'Markdown', ...gwizWinnersKeyboard() }); + return; + } + if (action.type === 'gwiz_await_custom_winners') { + if (!requireAdmin(ctx)) return; + const winners = Number(text); + if (!Number.isInteger(winners) || winners <= 0) { await ctx.reply('Please enter a valid whole number.'); return; } + action.data.maxWinners = winners; user.pendingAction = { type: 'gwiz_step_duration', data: action.data }; - await ctx.reply('*Step 4:* Countdown duration?', { parse_mode: 'Markdown', ...gwizDurationKeyboard() }); + await ctx.reply('*Step 4 of 9:* Countdown duration?', { parse_mode: 'Markdown', ...gwizDurationKeyboard() }); return; } if (action.type === 'gwiz_await_custom_duration') { @@ -4255,7 +4821,7 @@ bot.on('text', async (ctx) => { if (Number.isNaN(mins) || mins <= 0) { await ctx.reply('Please enter a valid duration in minutes.'); return; } action.data.durationMinutes = mins; user.pendingAction = { type: 'gwiz_step_minparts', data: action.data }; - await ctx.reply('*Step 5:* Minimum participants?', { parse_mode: 'Markdown', ...gwizMinPartsKeyboard() }); + await ctx.reply('*Step 5 of 9:* Minimum participants?', { parse_mode: 'Markdown', ...gwizMinPartsKeyboard() }); return; } if (action.type === 'gwiz_await_custom_minparts') { @@ -4265,8 +4831,8 @@ bot.on('text', async (ctx) => { action.data.minParticipants = minP; user.pendingAction = { type: 'gwiz_step_surface', data: action.data }; await ctx.reply( - '*Step 6:* Where can users join?', - { parse_mode: 'Markdown', ...gwizSurfaceKeyboard(action.data.joinSurface) }, + '*Step 6 of 9:* Join Targets — tap to toggle Group/DM ON/OFF.', + { parse_mode: 'Markdown', ...gwizSurfaceKeyboard(action.data) }, ); return; } @@ -4491,20 +5057,20 @@ bot.action('gwiz_title_skip', async (ctx) => { const user = getUser(ctx); if (!user.pendingAction || user.pendingAction.type !== 'gwiz_await_title') { await ctx.answerCbQuery(); return; } user.pendingAction.data.title = ''; - user.pendingAction = { type: 'gwiz_step_winners', data: user.pendingAction.data }; + user.pendingAction = { type: 'gwiz_step_sc', data: user.pendingAction.data }; await ctx.answerCbQuery(); - await ctx.reply('*Step 2:* How many winners?', { parse_mode: 'Markdown', ...gwizWinnersKeyboard() }); + await ctx.reply('*Step 2 of 9:* How many SC per winner?', { parse_mode: 'Markdown', ...gwizScKeyboard() }); }); -// ── Winners step ──────────────────────────────────────────────────────────── +// ── Winners step (Step 3) ──────────────────────────────────────────────────── async function gwizSetWinners(ctx, count) { if (!requireAdmin(ctx)) return; const user = getUser(ctx); - if (!user.pendingAction || !['gwiz_step_winners', 'gwiz_await_title'].includes(user.pendingAction.type)) { await ctx.answerCbQuery(); return; } + if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_winners') { await ctx.answerCbQuery(); return; } user.pendingAction.data.maxWinners = count; - user.pendingAction = { type: 'gwiz_step_sc', data: user.pendingAction.data }; + user.pendingAction = { type: 'gwiz_step_duration', data: user.pendingAction.data }; await ctx.answerCbQuery(); - await ctx.reply('*Step 3:* SC per winner?', { parse_mode: 'Markdown', ...gwizScKeyboard() }); + await ctx.reply('*Step 4 of 9:* Countdown duration?', { parse_mode: 'Markdown', ...gwizDurationKeyboard() }); } bot.action('gwiz_winners_1', (ctx) => gwizSetWinners(ctx, 1)); @@ -4514,21 +5080,21 @@ bot.action('gwiz_winners_5', (ctx) => gwizSetWinners(ctx, 5)); bot.action('gwiz_winners_custom', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); - if (!user.pendingAction) { await ctx.answerCbQuery(); return; } + if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_winners') { await ctx.answerCbQuery(); return; } user.pendingAction = { type: 'gwiz_await_custom_winners', data: user.pendingAction.data }; await ctx.answerCbQuery(); await ctx.reply('Type the number of winners:'); }); -// ── SC per winner step ────────────────────────────────────────────────────── +// ── SC per winner step (Step 2) ───────────────────────────────────────────── async function gwizSetSc(ctx, amount) { if (!requireAdmin(ctx)) return; const user = getUser(ctx); if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_sc') { await ctx.answerCbQuery(); return; } user.pendingAction.data.scPerWinner = amount; - user.pendingAction = { type: 'gwiz_step_duration', data: user.pendingAction.data }; + user.pendingAction = { type: 'gwiz_step_winners', data: user.pendingAction.data }; await ctx.answerCbQuery(); - await ctx.reply('*Step 4:* Countdown duration?', { parse_mode: 'Markdown', ...gwizDurationKeyboard() }); + await ctx.reply('*Step 3 of 9:* How many winners?', { parse_mode: 'Markdown', ...gwizWinnersKeyboard() }); } bot.action('gwiz_sc_5', (ctx) => gwizSetSc(ctx, 5)); @@ -4538,13 +5104,13 @@ bot.action('gwiz_sc_50', (ctx) => gwizSetSc(ctx, 50)); bot.action('gwiz_sc_custom', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); - if (!user.pendingAction) { await ctx.answerCbQuery(); return; } + if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_sc') { await ctx.answerCbQuery(); return; } user.pendingAction = { type: 'gwiz_await_custom_sc', data: user.pendingAction.data }; await ctx.answerCbQuery(); await ctx.reply('Type the SC amount per winner:'); }); -// ── Duration step ─────────────────────────────────────────────────────────── +// ── Duration step (Step 4) ────────────────────────────────────────────────── async function gwizSetDuration(ctx, minutes) { if (!requireAdmin(ctx)) return; const user = getUser(ctx); @@ -4552,7 +5118,7 @@ async function gwizSetDuration(ctx, minutes) { user.pendingAction.data.durationMinutes = minutes; user.pendingAction = { type: 'gwiz_step_minparts', data: user.pendingAction.data }; await ctx.answerCbQuery(); - await ctx.reply('*Step 5:* Minimum participants? (giveaway will wait if not met)', { parse_mode: 'Markdown', ...gwizMinPartsKeyboard() }); + await ctx.reply('*Step 5 of 9:* Minimum participants? (giveaway will wait if not met)', { parse_mode: 'Markdown', ...gwizMinPartsKeyboard() }); } bot.action('gwiz_dur_5', (ctx) => gwizSetDuration(ctx, 5)); @@ -4570,7 +5136,7 @@ bot.action('gwiz_dur_custom', async (ctx) => { await ctx.reply('Type the duration in minutes:'); }); -// ── Min participants step ─────────────────────────────────────────────────── +// ── Min participants step (Step 5) ────────────────────────────────────────── async function gwizSetMinParts(ctx, count) { if (!requireAdmin(ctx)) return; const user = getUser(ctx); @@ -4579,8 +5145,8 @@ async function gwizSetMinParts(ctx, count) { user.pendingAction = { type: 'gwiz_step_surface', data: user.pendingAction.data }; await ctx.answerCbQuery(); await ctx.reply( - '*Step 6:* Where can users join?\n\n📣 *Group* — join button in group chat only\n💬 *DM* — join button in bot DM only\n🌐 *Both* — group button + DM broadcast', - { parse_mode: 'Markdown', ...gwizSurfaceKeyboard(user.pendingAction.data.joinSurface) }, + '*Step 6 of 9:* Join Targets\n\nTap to toggle ON/OFF where users can join this giveaway.\n\n📣 *Group Chat* — join button posted in the group\n💬 *Bot DM* — users can join via DM (deep link sent)', + { parse_mode: 'Markdown', ...gwizSurfaceKeyboard(user.pendingAction.data) }, ); } @@ -4591,72 +5157,71 @@ bot.action('gwiz_minp_20', (ctx) => gwizSetMinParts(ctx, 20)); bot.action('gwiz_minp_custom', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); - if (!user.pendingAction) { await ctx.answerCbQuery(); return; } + if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_minparts') { await ctx.answerCbQuery(); return; } user.pendingAction = { type: 'gwiz_await_custom_minparts', data: user.pendingAction.data }; await ctx.answerCbQuery(); await ctx.reply('Type the minimum number of participants (0 = no minimum):'); }); -// ── Join surface step ─────────────────────────────────────────────────────── -async function gwizSetSurface(ctx, surface) { +// ── Join surface step (Step 6) — toggle group and DM independently ────────── +async function gwizToggleSurface(ctx, field) { if (!requireAdmin(ctx)) return; const user = getUser(ctx); if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_surface') { await ctx.answerCbQuery(); return; } - user.pendingAction.data.joinSurface = surface; - user.pendingAction = { type: 'gwiz_step_reqs', data: user.pendingAction.data }; - await ctx.answerCbQuery(); - await ctx.reply( - '*Step 7:* Toggle eligibility requirements (tap to toggle ON/OFF).\n\nTap ✅ Done when finished.', - { parse_mode: 'Markdown', ...gwizReqsKeyboard(user.pendingAction.data) }, - ); -} - -bot.action('gwiz_surface_group', (ctx) => gwizSetSurface(ctx, 'group')); -bot.action('gwiz_surface_dm', (ctx) => gwizSetSurface(ctx, 'dm')); -bot.action('gwiz_surface_both', (ctx) => gwizSetSurface(ctx, 'both')); - -// ── Requirements toggles ──────────────────────────────────────────────────── -async function gwizToggleReq(ctx, field) { - if (!requireAdmin(ctx)) return; - const user = getUser(ctx); - if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_reqs') { await ctx.answerCbQuery(); return; } const d = user.pendingAction.data; d[field] = !d[field]; await ctx.answerCbQuery(d[field] ? 'Enabled' : 'Disabled'); - // Edit the message keyboard in-place to reflect the new toggle state - await ctx.editMessageReplyMarkup(gwizReqsKeyboard(d).reply_markup).catch(async () => { - // If edit fails (e.g. inline edit not available), send a fresh reply - await ctx.reply('Requirements updated:', gwizReqsKeyboard(d)); + // Edit keyboard in-place to reflect toggle + await ctx.editMessageReplyMarkup(gwizSurfaceKeyboard(d).reply_markup).catch(async () => { + await ctx.reply('Surface updated:', gwizSurfaceKeyboard(d)); }); } -bot.action('gwiz_req_linked', (ctx) => gwizToggleReq(ctx, 'requireLinked')); -bot.action('gwiz_req_channel', (ctx) => gwizToggleReq(ctx, 'requireChannel')); -bot.action('gwiz_req_group', (ctx) => gwizToggleReq(ctx, 'requireGroup')); -bot.action('gwiz_req_age', (ctx) => gwizToggleReq(ctx, 'requireAge')); -bot.action('gwiz_req_verified', (ctx) => gwizToggleReq(ctx, 'requireVerified')); -bot.action('gwiz_req_promo', (ctx) => gwizToggleReq(ctx, 'requirePromo')); +bot.action('gwiz_surface_group', (ctx) => gwizToggleSurface(ctx, 'joinGroup')); +bot.action('gwiz_surface_dm', (ctx) => gwizToggleSurface(ctx, 'joinDm')); -// ── Confirm step ──────────────────────────────────────────────────────────── -bot.action('gwiz_reqs_done', async (ctx) => { +bot.action('gwiz_surface_done', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); - if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_reqs') { await ctx.answerCbQuery(); return; } + if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_surface') { await ctx.answerCbQuery(); return; } const d = user.pendingAction.data; - user.pendingAction = { type: 'gw_confirm', data: d }; // reuse existing confirm flow + // Compute joinSurface from toggles + if (d.joinGroup && d.joinDm) d.joinSurface = 'both'; + else if (d.joinDm) d.joinSurface = 'dm'; + else d.joinSurface = 'group'; + user.pendingAction = { type: 'gwiz_step_joininfo', data: d }; + await ctx.answerCbQuery(); + await ctx.reply( + `*Step 7 of 9:* Referral & Bonus Info\n\n` + + `The following message will be auto-included in the giveaway announcement:\n\n` + + `_"Earn 2× bonus SC when joining via referral link! Get your referral link with /referral."_\n\n` + + `Tap ✅ Include & Continue to proceed.`, + { parse_mode: 'Markdown', ...gwizJoinInfoKeyboard() }, + ); +}); + +// ── Referral info step (Step 7) ───────────────────────────────────────────── +bot.action('gwiz_joininfo_done', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_joininfo') { await ctx.answerCbQuery(); return; } + const d = user.pendingAction.data; + user.pendingAction = { type: 'gw_confirm', data: d }; await ctx.answerCbQuery(); await ctx.reply( gwizSummaryText(d), { parse_mode: 'Markdown', ...Markup.inlineKeyboard([ - [Markup.button.callback('🚀 Launch Giveaway!', 'gw_create_yes')], + [Markup.button.callback('🚀 Launch Giveaway! (Step 9)', 'gw_create_yes')], [Markup.button.callback('❌ Cancel', 'gw_create_no')], ]), }, ); }); +// gwiz_reqs_done removed — requirements step replaced by surface toggles + referral info step + bot.action('gw_create_yes', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); @@ -5017,18 +5582,30 @@ async function finalizeGiveaway(gwId, forceEnd = false) { for (const winner of giveaway.winners) { try { + // Apply 2× SC boost if the winner has an active referral boost + const winnerUser = userStore.get(winner.userId); + const hasBoostedPrize = winnerUser && winnerUser.boostExpiresAt > Date.now(); + const awardedSc = hasBoostedPrize ? giveaway.scPerWinner * 2 : giveaway.scPerWinner; + winner.awardedSc = awardedSc; + winner.boosted = hasBoostedPrize; // eslint-disable-next-line no-await-in-loop await bot.telegram.sendMessage( winner.userId, - `🎉 You won Giveaway #${giveaway.id}!\nPrize: ${giveaway.scPerWinner} SC\nRunewager username on file: ${winner.runewagerUsername}\nAdmin will handle prize distribution.`, + `🎉 You won Giveaway #${giveaway.id}!\n` + + `Prize: ${awardedSc} SC${hasBoostedPrize ? ' 🔥 (2× Referral Boost applied!)' : ''}\n` + + `Runewager username on file: ${winner.runewagerUsername || '(not linked)'}\n` + + `Admin will handle prize distribution.`, ); } catch (_) { // ignore DM failures (user may have blocked bot) } } + const winnerLines = giveaway.winners.map((w) => + `• ${w.tgUsername ? '@' + w.tgUsername : w.firstName || 'User'} — ${w.awardedSc || giveaway.scPerWinner} SC${w.boosted ? ' 🔥 boosted' : ''}` + ).join('\n'); await notifyAdmins( - `📊 Giveaway Report #${giveaway.id}\nChat ID: ${giveaway.chatId}\nSC each: ${giveaway.scPerWinner}\nTotal participants: ${eligibleCount}\nWinners:\n${renderWinnersList(giveaway.winners)}\n\nAdmin actions:\n- Reroll: /admin then callback gw_reroll_${giveaway.id}\n- Mark paid: callback gw_paid_${giveaway.id}\n- Export: callback gw_export_${giveaway.id}`, + `📊 Giveaway Report #${giveaway.id}\nChat ID: ${giveaway.chatId}\nBase SC each: ${giveaway.scPerWinner}\nTotal participants: ${eligibleCount}\nWinners:\n${winnerLines}\n\nAdmin actions:\n- Reroll: /admin then callback gw_reroll_${giveaway.id}\n- Mark paid: callback gw_paid_${giveaway.id}\n- Export: callback gw_export_${giveaway.id}`, ); }