From 209c68346566968084793753c5383aeeca1c4c39 Mon Sep 17 00:00:00 2001 From: GambleCodez Affiliates Date: Thu, 26 Feb 2026 15:38:39 -0600 Subject: [PATCH] Full giveaway system upgrade + test giveaway implementation + group linking + pinned announcements + payout flow + map sync --- RUNEWAGER_FUNCTIONALITY_MAP.md | 3 + index.js | 546 +++++++++++++++++++++++---------- 2 files changed, 389 insertions(+), 160 deletions(-) diff --git a/RUNEWAGER_FUNCTIONALITY_MAP.md b/RUNEWAGER_FUNCTIONALITY_MAP.md index b73b57d..2995f2b 100644 --- a/RUNEWAGER_FUNCTIONALITY_MAP.md +++ b/RUNEWAGER_FUNCTIONALITY_MAP.md @@ -326,6 +326,9 @@ Admin starts wizard (gwiz) - 2026-02-26: Completed a menu-integrity pass by adding explicit `Main Menu` + `Cancel` exits to admin category keyboards and settings submenu, then revalidated smoke/full tests. - 2026-02-26: Added global 15-minute pending-action timeout and slash-command escape fallbacks (`/cancel`, `/menu`, `/start`, `/help`) for wait-for-input state safety. + +- 2026-02-26: Full giveaway system upgrade completed: expanded Admin Giveaway menu (start/defaults/edit/extend/end/participants/group-linking/announcement builder/payout manager/test), added confirmation-gated 2-minute Test Giveaway flow (3 winners, 0 SC, 10 fake participants, admin winner guarantee), enforced test isolation from persistence/lists/history, added pinned announcement failure reporting, richer announcement content (Join + Open Bot + tips + referral boost + live counters), weighted winner selection with referral boost simulation, and bulk payout manager flow with post-payout history cleanup for real giveaways. + ## 25. AI Coder Contract Requirements Mandatory rules for any AI agent touching this repo: diff --git a/index.js b/index.js index f7b0a05..d4b7685 100644 --- a/index.js +++ b/index.js @@ -421,6 +421,7 @@ const REGISTERED_COMMANDS = new Set([ 'join', 'leaderboard', 'leaderboard_weekly', + 'language', 'link', 'linkaccount', 'linkrunewager', @@ -743,8 +744,8 @@ function createRuntimeStateSnapshot() { promoStoreCooldownDays: promoStore.cooldownDays || 0, giveaways: { counter: giveawayStore.counter || 1, - running: Array.from(giveawayStore.running.entries()).map(([id, g]) => ([id, serializeGiveaway(g)])), - history: Array.from(giveawayStore.history.entries()).map(([id, g]) => ([id, serializeGiveaway(g)])), + running: Array.from(giveawayStore.running.entries()).filter(([, g]) => !g.testMode).map(([id, g]) => ([id, serializeGiveaway(g)])), + history: Array.from(giveawayStore.history.entries()).filter(([, g]) => !g.testMode).map(([id, g]) => ([id, serializeGiveaway(g)])), }, tipsStore: { tips: tipsStore.tips, @@ -3196,7 +3197,15 @@ function adminStatsKeyboard() { function adminGiveawayToolsKeyboard() { return Markup.inlineKeyboard([ [Markup.button.callback('๐ŸŽ Start Giveaway', 'admin_cmd_start_giveaway')], - [Markup.button.callback('๐Ÿงช Test Giveaway', 'admin_cmd_testgiveaway')], + [Markup.button.callback('โš™๏ธ Giveaway Defaults', 'admin_gw_defaults')], + [Markup.button.callback('โœ๏ธ Edit Giveaway', 'admin_cmd_giveaway_status')], + [Markup.button.callback('โฑ Extend Giveaway', 'admin_cmd_giveaway_status')], + [Markup.button.callback('๐Ÿ›‘ End Giveaway', 'admin_cmd_giveaway_status')], + [Markup.button.callback('๐Ÿ‘ฅ View Participants', 'admin_cmd_giveaway_status')], + [Markup.button.callback('๐Ÿ”— Group Linking Tools', 'admin_gw_group_linking')], + [Markup.button.callback('๐Ÿ“ฃ Announcement Builder', 'admin_cmd_announce_start')], + [Markup.button.callback('๐Ÿ’ธ Winner Payout Manager', 'admin_gw_payout_manager')], + [Markup.button.callback('๐Ÿงช Run Test Giveaway', 'admin_cmd_testgiveaway')], [Markup.button.callback('๐Ÿ“Š Giveaway Status', 'admin_cmd_giveaway_status')], [Markup.button.callback('โฌ…๏ธ Admin Dashboard', 'open_admin_dashboard')], [Markup.button.callback('๐Ÿ  Main Menu', 'to_main_menu'), Markup.button.callback('โŒ Cancel', 'to_main_menu')], @@ -3813,7 +3822,7 @@ function buildSystemStatusText() { /** Build text listing active giveaways for admin */ function buildActiveGiveawaysText() { - const running = Array.from(giveawayStore.running.values()); + const running = Array.from(giveawayStore.running.values()).filter((g) => !g.testMode); 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) { @@ -6057,7 +6066,7 @@ bot.command('giveaway', async (ctx) => { // 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 running = Array.from(giveawayStore.running.values()).filter((g) => !g.testMode); const thisGroupGws = running.filter((g) => g.chatId === ctx.chat.id); const activeNote = thisGroupGws.length ? `\n\n๐Ÿ”ด ${thisGroupGws.length} active giveaway(s) in this group.` @@ -6720,7 +6729,7 @@ 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()); + const running = Array.from(giveawayStore.running.values()).filter((g) => !g.testMode); if (running.length === 0) { await ctx.reply( '๐Ÿ† *Giveaways*\n\nNo giveaways are currently running. Check back soon!', @@ -7895,7 +7904,107 @@ bot.action('admin_cmd_giveaway_status', async (ctx) => { bot.action('admin_cmd_testgiveaway', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('Use /testgiveaway to run a test giveaway.'); + const linkedGroups = Array.from(approvedGroupsStore); + const groupHint = linkedGroups.length + ? `Linked group detected: ${linkedGroups[0]}` + : 'No linked groups found yet.'; + await ctx.reply( + `โš ๏ธ *THIS IS ONLY A TEST GIVEAWAY โ€” NO REAL SC WILL BE SENT.* + +${groupHint} + +Start a 2-minute test giveaway with 3 winners and 10 simulated joins?`, + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('โœ… Confirm Test Start', 'tgw_confirm_start')], + [Markup.button.callback('โŒ Cancel', 'tgw_cancel')], + [Markup.button.callback('๐Ÿ”— Open Group Linking Tools', 'admin_gw_group_linking')], + [Markup.button.callback('๐Ÿ  Main Menu', 'to_main_menu')], + ]), + }, + ); +}); + + +bot.action('admin_gw_defaults', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await ctx.reply( + `โš™๏ธ Giveaway defaults are configured via the Start Giveaway wizard. + +Default profile: +โ€ข Duration: 30 min +โ€ข Winners: 1 +โ€ข SC per winner: 10 +โ€ข Requires linked username + age check + +Use *Start Giveaway* to override any value.`, + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('๐ŸŽ Start Giveaway', 'admin_cmd_start_giveaway')], + [Markup.button.callback('๐ŸŽ‰ Giveaway Tools', 'admin_cat_giveaway')], + [Markup.button.callback('๐Ÿ  Main Menu', 'to_main_menu')], + ]), + }, + ); +}); + +bot.action('admin_gw_group_linking', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + const groups = Array.from(approvedGroupsStore); + const lines = groups.length ? groups.map((id) => `โ€ข ${id}`).join('\n') : 'None linked yet.'; + await ctx.reply( + `๐Ÿ”— *Group Linking Tools* + +Linked groups for giveaways: +${lines} + +Use commands: +โ€ข /approve_group +โ€ข /unapprove_group +โ€ข /list_groups`, + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('๐Ÿงช Run Test Giveaway', 'admin_cmd_testgiveaway')], + [Markup.button.callback('โฌ…๏ธ Admin Dashboard', 'open_admin_dashboard')], + [Markup.button.callback('๐Ÿ  Main Menu', 'to_main_menu')], + ]), + }, + ); +}); + +bot.action('admin_gw_payout_manager', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + const unpaid = Array.from(giveawayStore.history.values()) + .filter((g) => g && !g.testMode && g.status === 'ended' && !g.paidOut && Array.isArray(g.winners) && g.winners.length > 0) + .slice(-10) + .reverse(); + if (!unpaid.length) { + await ctx.reply('๐Ÿ’ธ No unpaid real giveaway winners found.', Markup.inlineKeyboard([[Markup.button.callback('๐ŸŽ‰ Giveaway Tools', 'admin_cat_giveaway')], [Markup.button.callback('๐Ÿ  Main Menu', 'to_main_menu')]])); + return; + } + const summary = unpaid.map((g) => `โ€ข #${g.id} โ€” ${g.winners.length} winner(s) @ ${g.scPerWinner} SC`).join('\n'); + await ctx.reply( + `๐Ÿ’ธ *Winner Payout Manager* + +Unpaid giveaways: +${summary} + +Tap below to send payout confirmations and mark all as paid.`, + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [Markup.button.callback('โœ… Send All Tips / Mark All Paid', 'gw_payout_all')], + [Markup.button.callback('๐ŸŽ‰ Giveaway Tools', 'admin_cat_giveaway')], + [Markup.button.callback('๐Ÿ  Main Menu', 'to_main_menu')], + ]), + }, + ); }); bot.action('admin_cmd_testall', async (ctx) => { @@ -8694,14 +8803,15 @@ bot.action(/^gw_join_(\d+)$/, async (ctx) => { return; } - // Enforce joinSurface: DM-only giveaways can't be joined from a group button + const botUsername = (ctx.botInfo && ctx.botInfo.username) || ''; + const openBotUrl = botUsername ? `https://t.me/${botUsername}` : LINKS.miniAppPlay; const isPrivate = ctx.chat && ctx.chat.type === 'private'; + if (giveaway.joinSurface === 'dm' && !isPrivate) { await ctx.answerCbQuery('Join via DM only'); await ctx.reply('โš ๏ธ This giveaway can only be joined via the bot DM. Use the DM join link instead.'); return; } - // Group-only giveaways can't be joined from a DM button if (giveaway.joinSurface === 'group' && isPrivate) { await ctx.answerCbQuery('Join from group only'); await ctx.reply('โš ๏ธ This giveaway can only be joined from the group chat.'); @@ -8711,13 +8821,28 @@ bot.action(/^gw_join_(\d+)$/, async (ctx) => { const checks = evaluateEligibility(user, giveaway); if (!checks.ok) { await ctx.answerCbQuery(); - if (checks.missingLink) return linkedUsernameError(ctx); + if (checks.missingLink) { + try { + await bot.telegram.sendMessage(user.id, 'โš ๏ธ Please link your Runewager username first using /link to join giveaways.'); + } catch (_) { + // ignore + } + if (!isPrivate) { + await ctx.reply(`@${user.tgUsername || `user${user.id}`} you must link your Runewager username first.`, Markup.inlineKeyboard([[Markup.button.url('๐Ÿค– Open Bot', openBotUrl)]])); + } + return; + } await ctx.reply(checks.message, checks.keyboard || undefined); - // Feature 17: auto-DM the exact missing step if user is in a group chat if (ctx.chat && ctx.chat.type !== 'private') dmEligibilityFix(user, checks).catch(() => {}); return; } + if (!user.verified && !isPrivate) { + await ctx.reply(`@${user.tgUsername || `user${user.id}`} please open the bot first to complete setup before joining.`, Markup.inlineKeyboard([[Markup.button.url('๐Ÿค– Open Bot', openBotUrl)]])); + await ctx.answerCbQuery('Open bot required first'); + return; + } + if (giveaway.participants.has(user.id)) { await ctx.answerCbQuery('Already joined'); await ctx.reply('You already joined this giveaway.'); @@ -8732,15 +8857,22 @@ bot.action(/^gw_join_(\d+)$/, async (ctx) => { hasJoinedChannel: user.hasJoinedChannel, hasJoinedGroup: user.hasJoinedGroup, joinedAt: Date.now(), + isFake: false, + boostedWeight: user.boostExpiresAt > Date.now() ? 2 : 1, }); user.giveawayJoinedIds.add(gwId); - // Feature 22: record in personal giveaway history if (!user.giveawayHistory) user.giveawayHistory = []; - user.giveawayHistory.push({ id: gwId, joinedAt: Date.now(), won: false, wonSC: 0 }); - if (user.giveawayHistory.length > 100) user.giveawayHistory.shift(); + if (!giveaway.testMode) { + user.giveawayHistory.push({ id: gwId, joinedAt: Date.now(), won: false, wonSC: 0 }); + if (user.giveawayHistory.length > 100) user.giveawayHistory.shift(); + } await ctx.answerCbQuery('Joined'); - await ctx.reply("You're in! Stay tuned for the results after the countdown."); + await ctx.reply(`You're in! Live joined count: ${giveaway.participants.size}`); + + if (giveaway.testMode) { + await refreshGiveawayAnnouncement(giveaway, botUsername); + } }); bot.action(/^gw_details_(\d+)$/, async (ctx) => { @@ -8807,6 +8939,45 @@ bot.action(/^gw_paid_(\d+)$/, async (ctx) => { await ctx.reply(`Giveaway #${gwId} marked as paid out.`); }); + +bot.action('gw_payout_all', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery('Processing payouts...'); + const targets = Array.from(giveawayStore.history.values()) + .filter((g) => g && !g.testMode && g.status === 'ended' && !g.paidOut && Array.isArray(g.winners) && g.winners.length > 0); + if (!targets.length) { + await ctx.reply('No unpaid real giveaways found.'); + return; + } + + let giveawayCount = 0; + let winnerCount = 0; + for (const giveaway of targets) { + giveaway.paidOut = true; + giveawayCount += 1; + for (const winner of giveaway.winners) { + winnerCount += 1; + try { + // eslint-disable-next-line no-await-in-loop + await bot.telegram.sendMessage(winner.userId, `โœ… Your Giveaway #${giveaway.id} payout has been processed. +Amount: ${winner.awardedSc || giveaway.scPerWinner} SC.`); + } catch (_) { + // ignore DM errors + } + } + giveawayStore.history.delete(giveaway.id); + } + + await ctx.reply( + `โœ… Payout summary + +โ€ข Giveaways paid: ${giveawayCount} +โ€ข Winner payouts processed: ${winnerCount} +โ€ข Real giveaway logs wiped after payout: yes`, + Markup.inlineKeyboard([[Markup.button.callback('๐ŸŽ‰ Giveaway Tools', 'admin_cat_giveaway')], [Markup.button.callback('๐Ÿ  Main Menu', 'to_main_menu')]]), + ); +}); + bot.action(/^gw_extend_(\d+)$/, async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); @@ -10999,10 +11170,16 @@ async function announceGiveaway(giveaway, botUsername) { const text = [ `๐ŸŽ‰ *SC Giveaway Started!*${titleLine}`, `๐Ÿ† Winners: ${giveaway.maxWinners}`, - `๐Ÿ’ฐ Prize: ${giveaway.scPerWinner} SC each`, - `โฑ Duration: ${giveaway.durationMinutes} min`, + `๐Ÿ’ฐ SC amount per winner: ${giveaway.scPerWinner}`, + `โฑ Countdown timer: ${giveaway.durationMinutes} min`, + `โ„น๏ธ Referrals give 2ร— boost for 7 days`, minPartLine.trim() ? minPartLine.trim() : null, reqBlock.trim() ? reqBlock.trim() : null, + '*Helpful tips:*', + 'โ€ข Must open bot', + 'โ€ข Must have Runewager username linked', + 'โ€ข Must have joined Runewager', + `๐Ÿ‘ฅ Live joined count: ${giveaway.participants.size}`, `\n๐Ÿ‘‡ Tap below to join before countdown ends!`, ].filter(Boolean).join('\n'); @@ -11017,6 +11194,7 @@ async function announceGiveaway(giveaway, botUsername) { const kb = Markup.inlineKeyboard([ joinRows.length > 0 ? joinRows : [Markup.button.callback('โœ… Join Giveaway', `gw_join_${giveaway.id}`)], + [Markup.button.url('๐Ÿค– Open Bot', botUsername ? `https://t.me/${botUsername}` : LINKS.miniAppPlay)], [ Markup.button.callback('๐Ÿ“‹ Details', `gw_details_${giveaway.id}`), Markup.button.callback('๐Ÿงช My Eligibility', `gw_elig_${giveaway.id}`), @@ -11038,7 +11216,9 @@ async function announceGiveaway(giveaway, botUsername) { try { await bot.telegram.pinChatMessage(giveaway.chatId, sent.message_id, { disable_notification: true }); giveaway.pinnedMsgId = sent.message_id; - } catch (_) { /* no pin permission โ€” not fatal */ } + } catch (e) { + await notifyAdmins(`โš ๏ธ Failed to pin giveaway #${giveaway.id} in chat ${giveaway.chatId}: ${e.message}`); + } } // If DM surface, also send a DM join announcement to all opted-in users @@ -11243,7 +11423,21 @@ async function finalizeGiveaway(gwId, forceEnd = false) { return; } - giveaway.winners = pickRandomUnique(participants, Math.min(giveaway.maxWinners, eligibleCount)); + const weightedPool = []; + for (const participant of participants) { + const pUser = userStore.get(participant.userId); + const weight = pUser && pUser.boostExpiresAt > Date.now() ? 2 : 1; + for (let i = 0; i < weight; i += 1) weightedPool.push(participant); + } + const pickedIds = new Set(); + const selected = []; + while (selected.length < Math.min(giveaway.maxWinners, eligibleCount) && weightedPool.length > 0) { + const candidate = weightedPool[Math.floor(Math.random() * weightedPool.length)]; + if (!candidate || pickedIds.has(candidate.userId)) continue; + selected.push(candidate); + pickedIds.add(candidate.userId); + } + giveaway.winners = selected; if (!giveaway.dryRun) { await bot.telegram.sendMessage(giveaway.chatId, renderWinnersText(giveaway)).catch(() => {}); @@ -12194,55 +12388,79 @@ bot.command('testall', async (ctx) => { // ========================= // /testgiveaway โ€” Admin fake giveaway quick-test // ========================= -bot.command('testgiveaway', async (ctx) => { - if (!requireAdmin(ctx)) return; - - const chatId = ctx.chat.id; - const adminId = ctx.from.id; +function hasAnyLinkedGroup() { + return approvedGroupsStore.size > 0; +} - // Duration picker - await ctx.reply( - '๐Ÿงช *Test Giveaway Mode*\n\nThis creates a fake giveaway visible ONLY in this chat with 10 fake placeholder participants.\n\nChoose countdown duration:', - { - parse_mode: 'Markdown', - ...Markup.inlineKeyboard([ - [ - Markup.button.callback('30 sec', 'tgw_dur_30'), - Markup.button.callback('60 sec', 'tgw_dur_60'), - Markup.button.callback('90 sec', 'tgw_dur_90'), - ], - [Markup.button.callback('โŒ Cancel', 'tgw_cancel')], - ]), - }, - ); -}); +function buildGiveawayAnnouncementText(giveaway) { + const remainingSec = Math.max(0, Math.floor((giveaway.endTime - Date.now()) / 1000)); + const joinedCount = giveaway.participants.size; + const isTest = Boolean(giveaway.testMode); + const title = giveaway.title || (isTest ? 'TEST Giveaway' : 'SC Giveaway'); + const warning = isTest ? 'โš ๏ธ THIS IS ONLY A TEST GIVEAWAY โ€” NO REAL SC WILL BE SENT.\n\n' : ''; + return [ + `${warning}๐ŸŽ‰ *${title}*`, + `๐Ÿ† Winners: ${giveaway.maxWinners}`, + `๐Ÿ’ฐ SC per winner: ${giveaway.scPerWinner}`, + 'โ„น๏ธ Referrals give 2ร— boost for 7 days', + '', + '*Helpful tips:*', + 'โ€ข You must open the bot', + 'โ€ข You must have Runewager username linked', + 'โ€ข You must have joined Runewager', + '', + `๐Ÿ‘ฅ Joined: ${joinedCount}`, + `โฑ Countdown: ${remainingSec}s`, + ].join('\n'); +} -bot.action('tgw_cancel', async (ctx) => { - if (!requireAdmin(ctx)) return; - await ctx.answerCbQuery('Cancelled.'); - await ctx.reply('Test giveaway cancelled.'); -}); +function buildGiveawayAnnouncementKeyboard(giveaway, botUsername) { + const openBotUrl = botUsername ? `https://t.me/${botUsername}` : LINKS.miniAppPlay; + return Markup.inlineKeyboard([ + [Markup.button.callback('โœ… Join Giveaway', `gw_join_${giveaway.id}`)], + [Markup.button.url('๐Ÿค– Open Bot', openBotUrl)], + [Markup.button.callback('๐Ÿ‘ฅ View Participants', `tgw_participants_${giveaway.id}`)], + ]); +} -bot.action(/^tgw_dur_(\d+)$/, async (ctx) => { - if (!requireAdmin(ctx)) return; - await ctx.answerCbQuery(); +async function refreshGiveawayAnnouncement(giveaway, botUsername) { + if (!giveaway || !giveaway.announcementMsgId) return; + try { + await bot.telegram.editMessageText( + giveaway.chatId, + giveaway.announcementMsgId, + undefined, + buildGiveawayAnnouncementText(giveaway), + { parse_mode: 'Markdown', ...buildGiveawayAnnouncementKeyboard(giveaway, botUsername) }, + ); + } catch (_) { + // non-fatal if already updated/deleted + } +} - const durSec = Number(ctx.match[1]); - const chatId = ctx.chat.id; +async function startConfiguredTestGiveaway(ctx) { const adminId = ctx.from.id; - const startedAt = Date.now(); + if (!hasAnyLinkedGroup()) { + await ctx.reply( + `โŒ Cannot start test giveaway.\n\nTooltip: add at least one linked group first using /approve_group so the bot has a posting target.`, + Markup.inlineKeyboard([[Markup.button.callback('๐Ÿ”— Group Linking Tools', 'admin_gw_group_linking')], [Markup.button.callback('๐Ÿ  Main Menu', 'to_main_menu')]]), + ); + return; + } - // Create the test giveaway in-memory + const chatId = ctx.chat.id; + const botUsername = (ctx.botInfo && ctx.botInfo.username) || ''; const gw = createGiveaway({ chatId, chatTitle: `[TEST] ${ctx.chat.title || ctx.chat.first_name || 'Admin DM'}`, startedBy: adminId, - durationMinutes: durSec / 60, - maxWinners: 2, - scPerWinner: 10, + durationMinutes: 2, + maxWinners: 3, + scPerWinner: 0, + minParticipants: 0, requireChannel: false, requireGroup: false, - requireLinked: false, + requireLinked: true, requireAge: false, requireVerified: false, requirePromo: false, @@ -12251,71 +12469,92 @@ bot.action(/^tgw_dur_(\d+)$/, async (ctx) => { testMode: true, dryRun: true, }); - gw.endTime = Date.now() + durSec * 1000; gw.isTestGiveaway = true; - gw.errors = []; + gw.persistDisabled = true; + gw.excludeFromLists = true; - // Inject 10 fake participants const fakeNames = ['alice', 'bob', 'carol', 'dave', 'eve', 'frank', 'grace', 'henry', 'ivan', 'judy']; for (let i = 0; i < 10; i += 1) { - const fakeId = -(1000 + i); + const fakeId = -(50000 + i); gw.participants.set(fakeId, { userId: fakeId, tgUsername: fakeNames[i], + firstName: fakeNames[i], runewagerUsername: `${fakeNames[i]}_rw`, hasJoinedChannel: true, hasJoinedGroup: true, + joinedAt: Date.now(), isFake: true, + boostedWeight: i % 3 === 0 ? 2 : 1, }); } giveawayStore.running.set(gw.id, gw); + const sent = await bot.telegram.sendMessage( + gw.chatId, + buildGiveawayAnnouncementText(gw), + { parse_mode: 'Markdown', ...buildGiveawayAnnouncementKeyboard(gw, botUsername) }, + ); + gw.announcementMsgId = sent.message_id; + + try { + await bot.telegram.pinChatMessage(gw.chatId, sent.message_id, { disable_notification: true }); + gw.pinnedMsgId = sent.message_id; + } catch (e) { + await notifyAdmins(`โš ๏ธ Failed to pin test giveaway announcement in chat ${gw.chatId}: ${e.message}`); + } + + gw.endTimer = setTimeout(async () => { + const still = giveawayStore.running.get(gw.id); + if (!still || still.status !== 'running') return; + await runTestGiveawayFinale(still, adminId); + }, 2 * 60 * 1000); + + await ctx.reply('๐Ÿงช Test giveaway started: 2 minutes, 3 winners, 0 SC, 10 fake joined users.'); +} + +bot.command('testgiveaway', async (ctx) => { + if (!requireAdmin(ctx)) return; await ctx.reply( - `๐Ÿงช *Test Giveaway #${gw.id} Started!*\n\n` - + `โ€ข Duration: ${durSec} seconds\n` - + `โ€ข Winners: ${gw.maxWinners}\n` - + `โ€ข SC each: ${gw.scPerWinner} SC\n` - + `โ€ข Fake participants: 10\n` - + `โ€ข Chat: ${gw.chatTitle}\n\n` - + `โฑ Countdown: ${durSec} seconds`, + `โš ๏ธ *THIS IS ONLY A TEST GIVEAWAY โ€” NO REAL SC WILL BE SENT.*\n\nDo you want to start the test giveaway now?`, { parse_mode: 'Markdown', ...Markup.inlineKeyboard([ - [Markup.button.callback('๐Ÿ‘ฅ View Participants', `tgw_participants_${gw.id}`)], - [Markup.button.callback('โšก Trigger Winner Selection Now', `tgw_finalize_${gw.id}`)], - [Markup.button.callback('โ›” Cancel Test', `tgw_abort_${gw.id}`)], + [Markup.button.callback('โœ… Confirm Test Start', 'tgw_confirm_start')], + [Markup.button.callback('โŒ Cancel', 'tgw_cancel')], ]), }, ); +}); - // Auto-finalize after durSec. setTimeout is synchronous โ€” no race condition. - // eslint-disable-next-line require-atomic-updates - gw.endTimer = setTimeout(async () => { - const stillRunning = giveawayStore.running.get(gw.id); - if (!stillRunning || stillRunning.status !== 'running') return; - await runTestGiveawayFinale(gw, adminId); - }, durSec * 1000); +bot.action('tgw_confirm_start', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await startConfiguredTestGiveaway(ctx); +}); + +bot.action('tgw_cancel', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery('Cancelled.'); + await ctx.reply('Test giveaway cancelled.'); }); bot.action(/^tgw_participants_(\d+)$/, async (ctx) => { if (!requireAdmin(ctx)) return; const gwId = Number(ctx.match[1]); - const gw = giveawayStore.running.get(gwId) || giveawayStore.history.get(gwId); + const gw = giveawayStore.running.get(gwId); await ctx.answerCbQuery(); - if (!gw) { await ctx.reply('Test giveaway not found.'); return; } + if (!gw || !gw.isTestGiveaway) { await ctx.reply('Test giveaway not found.'); return; } const parts = Array.from(gw.participants.values()); const real = parts.filter((p) => !p.isFake); const fake = parts.filter((p) => p.isFake); - const realNames = real.map((p) => `@${p.tgUsername}`).join(', ') || 'none'; - const fakeNames = fake.map((p) => `@${p.tgUsername}`).join(', '); await ctx.reply( - `๐Ÿ‘ฅ Participants โ€” Test Giveaway #${gwId}\n\n` - + `Real joins: ${real.length}\n` - + `Fake (test) joins: ${fake.length}\n` - + `Total: ${parts.length}\n\n` - + `Real: ${realNames}\n` - + `Fake: ${fakeNames}`, + `๐Ÿ‘ฅ Participants โ€” Test Giveaway #${gwId} + +Real joins: ${real.length} +Fake joins: ${fake.length} +Total: ${parts.length}`, Markup.inlineKeyboard([[Markup.button.callback('โšก Finalize Now', `tgw_finalize_${gwId}`), Markup.button.callback('โ›” Abort', `tgw_abort_${gwId}`)]]), ); }); @@ -12337,97 +12576,84 @@ bot.action(/^tgw_abort_(\d+)$/, async (ctx) => { await ctx.answerCbQuery('Aborted.'); if (!gw) { await ctx.reply('Test giveaway not found.'); return; } if (gw.endTimer) { clearTimeout(gw.endTimer); gw.endTimer = null; } + if (gw.pinnedMsgId) { bot.telegram.unpinChatMessage(gw.chatId, gw.pinnedMsgId).catch(() => {}); } giveawayStore.running.delete(gwId); - giveawayStore.history.set(gwId, { ...gw, status: 'cancelled_test' }); await ctx.reply(`๐Ÿงช Test Giveaway #${gwId} aborted and cleaned up.`); }); -/** - - * runTestGiveawayFinale executes its scoped Runewager logic and participates in menu/command or utility flow composition. - - * Parameters: See the function signature for exact argument names and accepted values. - - * Returns: Returns the computed value or a Promise resolving to the operation result; may return void for side-effect handlers. - - * Side effects: May mutate runtime stores, pendingAction state, menu state, persistence files, logs, and callback progression. - - * Validation/safety: Uses existing guard utilities (admin checks, input checks, path checks, cooldown checks) where applicable. - - * Timeouts/fallbacks: Timeout and fallback behavior are controlled by the calling flow and global handler/state machine conventions. - - * Errors: Surfaces user-facing error replies and/or logs when inputs, permissions, or dependencies are invalid. - - * System fit: This function is part of the Runewager command/callback/state orchestration pipeline. - - */ - async function runTestGiveawayFinale(gw, adminId) { const gwId = gw.id; - const allParticipants = Array.from(gw.participants.values()); - const realParticipants = allParticipants.filter((p) => !p.isFake); - const fakeParticipants = allParticipants.filter((p) => p.isFake); - - // Pick winners from all participants (real + fake) - const pool = allParticipants; - const winnerCount = Math.min(gw.maxWinners, pool.length); - const winnerEntries = pickRandomUnique(pool, winnerCount); - gw.winners = winnerEntries; - gw.status = 'ended_test'; - - giveawayStore.running.delete(gwId); - giveawayStore.history.set(gwId, gw); - - const winnerList = winnerEntries.map((w) => `@${w.tgUsername}${w.isFake ? ' (fake)' : ' โญ REAL'}`).join('\n'); - const durationMs = Date.now() - gw.createdAt; - const durationSec = Math.round(durationMs / 1000); - - const winnerNames = winnerEntries.map((w) => `@${w.tgUsername}${w.isFake ? '' : ' โญ'}`).join(', '); - - const structuredReport = [ - `๐ŸŽ‰ *Giveaway Test Report*`, - `Fake Giveaway ID: TEST-${gwId}`, - `Duration: ${durationSec}s`, - `Join Check: OK`, - `Entry Validation: OK`, - `Winner Selection: OK (${winnerEntries.length} winner(s) from ${allParticipants.length} participant(s))`, - `Admin Report: OK`, - ``, - `Winner(s): ${winnerNames || 'none'}`, - `Prize: ${gw.scPerWinner} SC each (test/dry-run โ€” no real SC sent)`, - `Real user joins: ${realParticipants.length}`, - `Fake (test) joins: ${fakeParticipants.length}`, - gw.errors && gw.errors.length > 0 ? `Errors: ${gw.errors.join(', ')}` : `Errors: none`, - ``, - `โœ… Test complete. No real users or SC affected.`, - ].join('\n'); + const participants = Array.from(gw.participants.values()); - try { - await bot.telegram.sendMessage(adminId, structuredReport, { parse_mode: 'Markdown' }); - } catch (e) { - logEvent('error', 'testgiveaway finale notify failed', { error: e.message }); + // weighted pool for referral boost simulation + const weighted = []; + for (const p of participants) { + const w = p.boostedWeight || 1; + for (let i = 0; i < w; i += 1) weighted.push(p); } -} -/** + const winners = []; + const picked = new Set(); + const adminUser = userStore.get(adminId) || null; + if (adminUser) { + winners.push({ + userId: adminUser.id, + tgUsername: adminUser.tgUsername || `user${adminUser.id}`, + firstName: adminUser.firstName || '', + runewagerUsername: adminUser.runewagerUsername || '', + isFake: false, + }); + picked.add(adminUser.id); + } - * startHealthServer executes its scoped Runewager logic and participates in menu/command or utility flow composition. + while (winners.length < Math.min(gw.maxWinners, participants.length) && weighted.length) { + const pick = weighted[Math.floor(Math.random() * weighted.length)]; + if (!pick || picked.has(pick.userId)) continue; + winners.push(pick); + picked.add(pick.userId); + } - * Parameters: See the function signature for exact argument names and accepted values. + gw.winners = winners; + gw.status = 'ended_test'; + giveawayStore.running.delete(gwId); - * Returns: Returns the computed value or a Promise resolving to the operation result; may return void for side-effect handlers. + if (gw.pinnedMsgId) { + bot.telegram.unpinChatMessage(gw.chatId, gw.pinnedMsgId).catch(() => {}); + } - * Side effects: May mutate runtime stores, pendingAction state, menu state, persistence files, logs, and callback progression. + const winnerLines = winners.map((w) => `โ€ข @${w.tgUsername || `user${w.userId}`}${w.isFake ? ' (fake)' : ''}`).join('\n') || 'none'; + await bot.telegram.sendMessage( + gw.chatId, + `๐Ÿงช Test Giveaway #${gwId} ended. - * Validation/safety: Uses existing guard utilities (admin checks, input checks, path checks, cooldown checks) where applicable. +Winners (${winners.length}): +${winnerLines}`, + ).catch(() => {}); - * Timeouts/fallbacks: Timeout and fallback behavior are controlled by the calling flow and global handler/state machine conventions. + for (const winner of winners) { + if (winner.isFake) continue; + try { + // eslint-disable-next-line no-await-in-loop + await bot.telegram.sendMessage(winner.userId, 'You are a TEST giveaway winner! This was only a test.'); + } catch (_) { + // ignore dm issues + } + } - * Errors: Surfaces user-facing error replies and/or logs when inputs, permissions, or dependencies are invalid. + await bot.telegram.sendMessage( + adminId, + `๐Ÿงช Test giveaway summary - * System fit: This function is part of the Runewager command/callback/state orchestration pipeline. +ID: ${gwId} +Participants: ${participants.length} +Winners: ${winners.length} +${winnerLines}`, + ).catch(() => {}); - */ + // wipe all test logs/state (do not persist) + gw.participants.clear(); + gw.winners = []; +} function startHealthServer() { const port = Number(process.env.PORT || 3000);