diff --git a/index.js b/index.js index 969daae..8bcd5f8 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,16 @@ function createDefaultUser(user) { showQuickCommands: true, // show quick-access row under menu 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 @@ -450,6 +467,8 @@ 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(); + user.commandsUsed = (user.commandsUsed || 0) + 1; if (!user.wagerBonus) { user.wagerBonus = { attempts: 0, @@ -844,7 +863,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 +887,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 +964,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) }, ); } @@ -968,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+ โœ…'); @@ -1083,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 = {}) { @@ -1188,19 +1490,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; @@ -1238,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)' }, @@ -1249,10 +1597,24 @@ 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: 'leaderboard', description: 'Global referral leaderboard' }, - { command: 'leaderboard_weekly', description: 'Weekly referral leaderboard' }, - { command: 'start_giveaway', description: '(Admin) Start SC giveaway' }, + { 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: '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 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: '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 +1625,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 +1691,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) { @@ -1356,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) => { @@ -1369,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) => { @@ -1687,7 +2100,18 @@ bot.command('walkthrough', async (ctx) => { bot.command('admin', async (ctx) => { if (!requireAdmin(ctx)) return; - await ctx.reply('๐Ÿ”ง Admin Controls', adminKeyboard()); + 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 + 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 persistent admin menu + await sendPersistentAdminMenu(ctx, user); }); bot.command('admin_help', async (ctx) => { @@ -1696,12 +2120,198 @@ 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: 1.title โ†’ 2.sc โ†’ 3.winners โ†’ 4.duration โ†’ 5.minParts โ†’ 6.surface(group+DM toggles) โ†’ 7.referralInfo โ†’ 8.confirm โ†’ 9.post +// ========================= + +/** Keyboard for step: SC per winner (Step 2) */ +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: number of winners (Step 3) */ +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: 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 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(`${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')], + ]); +} + +/** Step 7: Referral + bonus info โ€” auto-include, just show info + proceed button */ +function gwizJoinInfoKeyboard() { + return Markup.inlineKeyboard([ + [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}`, + `โฑ Duration: ${d.durationMinutes} min`, + `๐Ÿ‘ฅ Min participants: ${d.minParticipants || 'none'}`, + `๐Ÿ“ฃ Join surfaces: ${surfaceStr}`, + ``, + `*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'); +} + +/** + * Start the inline giveaway wizard. + * config = { chatId, chatTitle, startedBy } + */ +async function gwizStart(ctx, user, config) { + clearPendingAction(user); + const data = { + chatId: config.chatId || (ctx.chat && ctx.chat.id), + chatTitle: config.chatTitle || (ctx.chat && ctx.chat.title) || 'DM', + startedBy: config.startedBy || ctx.from.id, + title: '', + scPerWinner: 10, + maxWinners: 1, + durationMinutes: 30, + minParticipants: 0, + joinGroup: true, + joinDm: false, + joinSurface: 'group', // computed from joinGroup/joinDm before launch + requireLinked: true, + 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 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([ + [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) => { @@ -2045,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) => { @@ -2258,48 +2868,363 @@ bot.command('setpromo', async (ctx) => { await ctx.reply('Enter new promo display message (shown to users on /promo and claim flow). Type /cancel to abort.'); }); - -bot.command('join', async (ctx) => { + +bot.command('join', async (ctx) => { + const user = getUser(ctx); + if (ctx.chat.type === 'private') { + await ctx.reply('Use /join in a group chat where a giveaway is running.'); + return; + } + const active = Array.from(giveawayStore.running.values()).find((g) => String(g.chatId) === String(ctx.chat.id)); + if (!active) { + await ctx.reply('No active giveaway in this chat right now.'); + return; + } + const checks = evaluateEligibility(user, active); + if (!checks.ok) { + if (checks.missingLink) return linkedUsernameError(ctx); + await ctx.reply(checks.message, checks.keyboard || undefined); + return; + } + if (active.participants.has(user.id)) { + await ctx.reply('You already joined this giveaway. Stand by for the draw!'); + return; + } + active.participants.set(user.id, { + userId: user.id, + tgUsername: user.tgUsername || `user${user.id}`, + runewagerUsername: user.runewagerUsername, + hasJoinedChannel: user.hasJoinedChannel, + hasJoinedGroup: user.hasJoinedGroup, + }); + user.giveawayJoinedIds.add(active.id); + await ctx.reply(`You're in giveaway #${active.id}! Stay tuned for the draw.`); +}); + + + +// ========================= +// Callback handlers +// ========================= +bot.action('to_main_menu', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + 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); - if (ctx.chat.type === 'private') { - await ctx.reply('Use /join in a group chat where a giveaway is running.'); - return; - } - const active = Array.from(giveawayStore.running.values()).find((g) => String(g.chatId) === String(ctx.chat.id)); - if (!active) { - await ctx.reply('No active giveaway in this chat right now.'); - return; - } - const checks = evaluateEligibility(user, active); - if (!checks.ok) { - if (checks.missingLink) return linkedUsernameError(ctx); - await ctx.reply(checks.message, checks.keyboard || undefined); - return; - } - if (active.participants.has(user.id)) { - await ctx.reply('You already joined this giveaway. Stand by for the draw!'); - return; + 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 */ } } - active.participants.set(user.id, { - userId: user.id, - tgUsername: user.tgUsername || `user${user.id}`, - runewagerUsername: user.runewagerUsername, - hasJoinedChannel: user.hasJoinedChannel, - hasJoinedGroup: user.hasJoinedGroup, - }); - user.giveawayJoinedIds.add(active.id); - await ctx.reply(`You're in giveaway #${active.id}! Stay tuned for the draw.`); + giveawayStore.running.delete(gwId); + await ctx.reply(`๐Ÿ—‘ Giveaway #${gwId} cancelled. No winners selected.`); + await sendPersistentAdminMenu(ctx, user); }); - - -// ========================= -// Callback handlers -// ========================= -bot.action('to_main_menu', async (ctx) => { +bot.action(/^pamenu_gw_participants_(\d+)$/, async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); - await sendMainMenu(ctx, user); + 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) => { @@ -2315,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( @@ -2356,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) => { @@ -2557,13 +3482,18 @@ 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( - '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) => { @@ -2674,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) => { @@ -2775,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) => { @@ -2831,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) => { @@ -2940,6 +3874,74 @@ 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); + 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}*`, + ``, + `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}`, + ``, + `*Referral Data:*`, + ` Referrals in window: ${referralsInWindow}`, + ` Users with active 2ร— boost: ${activeBoosted}`, + ].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 +3961,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 +4397,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 +4789,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_sc', data: action.data }; + await ctx.reply('*Step 2 of 9:* How many 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_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 of 9:* 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 of 9:* 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 of 9:* Join Targets โ€” tap to toggle Group/DM ON/OFF.', + { parse_mode: 'Markdown', ...gwizSurfaceKeyboard(action.data) }, + ); + 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 +5028,200 @@ 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_sc', data: user.pendingAction.data }; + await ctx.answerCbQuery(); + await ctx.reply('*Step 2 of 9:* How many SC per winner?', { parse_mode: 'Markdown', ...gwizScKeyboard() }); +}); + +// โ”€โ”€ Winners step (Step 3) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +async function gwizSetWinners(ctx, count) { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_winners') { await ctx.answerCbQuery(); return; } + user.pendingAction.data.maxWinners = count; + user.pendingAction = { type: 'gwiz_step_duration', data: user.pendingAction.data }; + await ctx.answerCbQuery(); + await ctx.reply('*Step 4 of 9:* Countdown duration?', { parse_mode: 'Markdown', ...gwizDurationKeyboard() }); +} + +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 || 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 (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_winners', data: user.pendingAction.data }; + await ctx.answerCbQuery(); + await ctx.reply('*Step 3 of 9:* How many winners?', { parse_mode: 'Markdown', ...gwizWinnersKeyboard() }); +} + +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 || 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 (Step 4) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +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 of 9:* 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 (Step 5) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +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 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) }, + ); +} + +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 || 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 (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; } + const d = user.pendingAction.data; + d[field] = !d[field]; + await ctx.answerCbQuery(d[field] ? 'Enabled' : 'Disabled'); + // 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_surface_group', (ctx) => gwizToggleSurface(ctx, 'joinGroup')); +bot.action('gwiz_surface_dm', (ctx) => gwizToggleSurface(ctx, 'joinDm')); + +bot.action('gwiz_surface_done', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + if (!user.pendingAction || user.pendingAction.type !== 'gwiz_step_surface') { await ctx.answerCbQuery(); return; } + const d = user.pendingAction.data; + // 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! (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); @@ -3974,10 +5233,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 +5372,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 +5392,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 +5524,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,23 +5577,35 @@ 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) { 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 (e) { - // ignore DM failures + } 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}`, ); }