From 58a09f6c4a1737543ce521be73cf73a44896e9cf Mon Sep 17 00:00:00 2001 From: GambleCodez Affiliates Date: Wed, 25 Feb 2026 02:57:33 -0600 Subject: [PATCH] Add DB-backed admin promo manager and claim workflow --- index.js | 1057 +++++++++++++++++++++++------------------------------- 1 file changed, 451 insertions(+), 606 deletions(-) diff --git a/index.js b/index.js index 3215ada..cb44840 100644 --- a/index.js +++ b/index.js @@ -43,6 +43,12 @@ const LINKS = { }; const DEFAULT_BONUS_RULE = `Wager 3,000 SC in any rolling 7-day period โ†’ 30 SC bonus (once per account). Need help? Contact @GambleCodez on Telegram or open a support ticket: ${LINKS.rwDiscordSupport}`; +const PROMO_STATUS = { ACTIVE: 'active', PAUSED: 'paused', DELETED: 'deleted' }; +const PROMO_REQUIREMENT = { + NEW_USER_ONLY: 'new_user_only', + EXISTING_USER: 'existing_user', + EXISTING_USER_WITH_WAGER: 'existing_user_with_wager_requirement', +}; // Channel username (or chat_id) for admin announcements via /announce const ANNOUNCE_CHANNEL = process.env.ANNOUNCE_CHANNEL || '@GambleCodezDrops'; @@ -148,47 +154,23 @@ bot.catch((err, ctx) => { // ========================= const userStore = new Map(); const promoStore = { - active: true, - code: process.env.PROMO_CODE || 'Sports3.5', - amountSC: Number(process.env.PROMO_AMOUNT_SC) || 3.5, - totalClaimLimit: Number(process.env.PROMO_CLAIM_LIMIT) || 600, - remainingClaims: Number(process.env.PROMO_CLAIM_LIMIT) || 600, - bonusRule: process.env.PROMO_BONUS_RULE || DEFAULT_BONUS_RULE, - claimsByUser: new Set(), + bonusRule: DEFAULT_BONUS_RULE, + bugreports: [], logs: [], - cooldownDays: 0, // Feature 10: 0 = no cooldown; >0 = days between claims per user -}; - -const PROMO_AUDIENCE = { - NEW_USER: 'new_user', - EXISTING_USER: 'existing_user', }; -const PROMO_REQUIREMENT_TYPE = { - WAGER: 'wager', - NO_REQUIREMENT: 'no_requirement', +const promoManagerStore = { + nextPromoId: 1, + nextClaimId: 1, + promos: [], + claims: [], + eligibilityFailures: [], }; -const PROMO_GLOBAL_COOLDOWN_MS = 24 * 60 * 60 * 1000; - -const DEFAULT_PROMO_CODES = [ - { - id: 1, - code: process.env.PROMO_CODE || 'Sports3.5', - audience: PROMO_AUDIENCE.NEW_USER, - requirementType: PROMO_REQUIREMENT_TYPE.WAGER, - amountSC: Number(process.env.PROMO_AMOUNT_SC) || 3.5, - notes: 'New user welcome bonus', - active: true, - createdAt: Date.now(), - updatedAt: Date.now(), - }, -]; - -const promoCodeStore = { - nextId: 2, - codes: DEFAULT_PROMO_CODES.map((code) => ({ ...code })), -}; +// Legacy compatibility placeholders (promo logic now lives in promoManagerStore only) +const PROMO_AUDIENCE = { NEW_USER: 'new_user', EXISTING_USER: 'existing_user' }; +const PROMO_REQUIREMENT_TYPE = { WAGER: 'wager', NO_REQUIREMENT: 'no_requirement' }; +const promoCodeStore = { nextId: 1, codes: [] }; const giveawayStore = { running: new Map(), @@ -233,6 +215,7 @@ const promoHistoryFile = path.join(dataDir, 'promo-history.json'); const analyticsFile = path.join(dataDir, 'analytics.json'); const dashboardFile = path.join(dataDir, 'dashboard.json'); const runtimeStateFile = path.join(dataDir, 'runtime-state.json'); +const promoManagerDbFile = path.join(dataDir, 'promo-manager-db.json'); const helpfulMessagesFile = path.join(dataDir, 'helpful_messages.json'); const sshvSessionsFile = path.join(dataDir, 'sshv-sessions.json'); @@ -598,6 +581,23 @@ function deserializeGiveaway(rawGiveaway) { }; } +function normalizePromoCodeEntry(raw, idHint = null) { + if (!raw || typeof raw !== 'object') return null; + const code = String(raw.code || '').trim(); + if (!code) return null; + return { + id: Number(raw.id) || Number(idHint || 0), + code, + audience: raw.audience || PROMO_AUDIENCE.NEW_USER, + requirementType: raw.requirementType || PROMO_REQUIREMENT_TYPE.NO_REQUIREMENT, + amountSC: Number(raw.amountSC) || 0, + notes: String(raw.notes || ''), + active: raw.active !== false, + createdAt: Number(raw.createdAt) || Date.now(), + updatedAt: Number(raw.updatedAt) || Date.now(), + }; +} + // Startup warnings deferred until after bot.launch() so Telegram API is available const _startupWarnings = []; @@ -645,7 +645,7 @@ function loadRuntimeState() { : []; promoCodeStore.codes = loadedCodes.length > 0 ? loadedCodes - : DEFAULT_PROMO_CODES.map((code) => ({ ...code })); + : []; const maxId = promoCodeStore.codes.reduce((acc, code) => Math.max(acc, Number(code.id) || 0), 0); promoCodeStore.nextId = Math.max(Number(raw.promoCodeStore.nextId) || 0, maxId + 1, 2); } @@ -705,6 +705,7 @@ function loadRuntimeState() { function persistRuntimeState() { const snapshot = createRuntimeStateSnapshot(); saveJson(runtimeStateFile, snapshot); + savePromoManagerDb(); lastPersistAt = Date.now(); } @@ -1583,86 +1584,136 @@ function adminKeyboard() { } function promoText() { - return `๐ŸŽ NEW USER BONUS โ€” ONCE PER ACCOUNT/IP\nPromo Code: ${promoStore.code}\nAmount: ${promoStore.amountSC} SC each\nRemaining Claims: ${promoStore.remainingClaims}\nTotal Claims: ${promoStore.totalClaimLimit}\n\n${promoStore.bonusRule}`; + return promoManagerSummaryText ? promoManagerSummaryText() : 'Promo Manager enabled.'; } -function normalizePromoCodeEntry(raw, idHint = null) { +function normalizePromoManagerPromo(raw, idHint = null) { if (!raw || typeof raw !== 'object') return null; - const code = String(raw.code || '').trim(); - if (!code) return null; - const audience = raw.audience === PROMO_AUDIENCE.EXISTING_USER - ? PROMO_AUDIENCE.EXISTING_USER - : PROMO_AUDIENCE.NEW_USER; - const requirementType = raw.requirementType === PROMO_REQUIREMENT_TYPE.NO_REQUIREMENT - ? PROMO_REQUIREMENT_TYPE.NO_REQUIREMENT - : PROMO_REQUIREMENT_TYPE.WAGER; - const parsedAmount = Number(raw.amountSC); - const amountSC = Number.isFinite(parsedAmount) && parsedAmount > 0 ? parsedAmount : 0; - const parsedId = Number(raw.id); - const id = Number.isFinite(parsedId) && parsedId > 0 ? parsedId : Number(idHint || 0); - if (!id) return null; + const parsedId = Number(raw.promo_id); + const promoId = Number.isFinite(parsedId) && parsedId > 0 ? parsedId : Number(idHint || 0); + if (!promoId) return null; + const requirementType = Object.values(PROMO_REQUIREMENT).includes(raw.requirement_type) + ? raw.requirement_type + : PROMO_REQUIREMENT.NEW_USER_ONLY; return { - id, - code, - audience, - requirementType, - amountSC, - notes: typeof raw.notes === 'string' ? raw.notes.trim() : '', - active: raw.active !== false, - createdAt: Number(raw.createdAt) || Date.now(), - updatedAt: Number(raw.updatedAt) || Date.now(), + promo_id: promoId, + name: String(raw.name || '').trim() || `Promo #${promoId}`, + code_or_link: String(raw.code_or_link || '').trim(), + casino_base_url: String(raw.casino_base_url || '').trim(), + description: String(raw.description || '').trim(), + image_url: String(raw.image_url || '').trim(), + requirement_type: requirementType, + required_wager_amount: Number(raw.required_wager_amount) || 0, + required_wager_days: Number(raw.required_wager_days) || 0, + new_user_only: requirementType === PROMO_REQUIREMENT.NEW_USER_ONLY, + existing_user_only: requirementType !== PROMO_REQUIREMENT.NEW_USER_ONLY, + claim_limit: Number(raw.claim_limit) > 0 ? Number(raw.claim_limit) : null, + cooldown_hours: Number(raw.cooldown_hours) > 0 ? Number(raw.cooldown_hours) : 0, + auto_approve: raw.auto_approve !== false, + created_by: Number(raw.created_by) || 0, + created_at: Number(raw.created_at) || Date.now(), + updated_at: Number(raw.updated_at) || Date.now(), + status: Object.values(PROMO_STATUS).includes(raw.status) ? raw.status : PROMO_STATUS.ACTIVE, }; } -function getActivePromoCodeForUser(user) { - const wantsNewUser = !user.claimedPromo; - const audience = wantsNewUser ? PROMO_AUDIENCE.NEW_USER : PROMO_AUDIENCE.EXISTING_USER; - const candidates = promoCodeStore.codes - .filter((code) => code.active && code.audience === audience) - .sort((a, b) => b.updatedAt - a.updatedAt); - return candidates[0] || null; +function savePromoManagerDb() { + saveJson(promoManagerDbFile, promoManagerStore); +} + +function loadPromoManagerDb() { + const raw = loadJson(promoManagerDbFile, null); + if (!raw || typeof raw !== 'object') return; + promoManagerStore.nextPromoId = Math.max(1, Number(raw.nextPromoId) || 1); + promoManagerStore.nextClaimId = Math.max(1, Number(raw.nextClaimId) || 1); + promoManagerStore.promos = Array.isArray(raw.promos) + ? raw.promos.map((promo) => normalizePromoManagerPromo(promo, promo && promo.promo_id)).filter(Boolean) + : []; + promoManagerStore.claims = Array.isArray(raw.claims) ? raw.claims : []; + promoManagerStore.eligibilityFailures = Array.isArray(raw.eligibilityFailures) ? raw.eligibilityFailures.slice(-2000) : []; + const maxPromoId = promoManagerStore.promos.reduce((acc, promo) => Math.max(acc, Number(promo.promo_id) || 0), 0); + promoManagerStore.nextPromoId = Math.max(promoManagerStore.nextPromoId, maxPromoId + 1); + const maxClaimId = promoManagerStore.claims.reduce((acc, claim) => Math.max(acc, Number(claim.claim_id) || 0), 0); + promoManagerStore.nextClaimId = Math.max(promoManagerStore.nextClaimId, maxClaimId + 1); +} + +function listActivePromos() { + return promoManagerStore.promos.filter((promo) => promo.status === PROMO_STATUS.ACTIVE); +} + +function getPromoById(promoId) { + return promoManagerStore.promos.find((promo) => promo.promo_id === Number(promoId)) || null; +} + +function hasRedeemedAnyPromo(userId) { + return promoManagerStore.claims.some((claim) => claim.user_id === Number(userId) && ['claimed', 'approved'].includes(claim.status)); } -function getPromoClaimCooldownRemainingMs(user) { - if (!user.lastAnyPromoClaimAt) return 0; - const elapsed = Date.now() - Number(user.lastAnyPromoClaimAt || 0); - return Math.max(0, PROMO_GLOBAL_COOLDOWN_MS - elapsed); +function countPromoClaims(promoId) { + return promoManagerStore.claims.filter((claim) => claim.promo_id === Number(promoId) && ['claimed', 'approved'].includes(claim.status)).length; } -function formatCooldown(ms) { - const totalMinutes = Math.ceil(ms / 60000); - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - if (hours <= 0) return `${minutes}m`; - return `${hours}h ${minutes}m`; +async function getRollingWagerForUser(user, days) { + if (Number(days) === 7 && user.wagerBonus && Number(user.wagerBonus.wager7dayTotal) > 0) return Number(user.wagerBonus.wager7dayTotal); + return Number(user.wagerBonus && user.wagerBonus.wager7dayTotal) || 0; } -function formatPromoCodeLine(code) { - const audience = code.audience === PROMO_AUDIENCE.NEW_USER ? 'new-user' : 'existing-user'; - const requirement = code.requirementType === PROMO_REQUIREMENT_TYPE.NO_REQUIREMENT ? 'no-requirement' : 'wager'; - return `#${code.id} | ${code.code} | ${audience} | ${requirement} | ${code.active ? 'active' : 'paused'}`; +async function evaluatePromoEligibility(user, promo, { logFailure = false } = {}) { + const reasons = []; + const userId = Number(user.id); + const anyRedeemed = hasRedeemedAnyPromo(userId); + if (promo.new_user_only && anyRedeemed) reasons.push('New-user promo only. You already redeemed a promo.'); + if (promo.existing_user_only && !promo.new_user_only && !anyRedeemed) reasons.push('This promo is only for existing promo users.'); + if (promo.requirement_type === PROMO_REQUIREMENT.EXISTING_USER_WITH_WAGER) { + const wager = await getRollingWagerForUser(user, promo.required_wager_days || 7); + if (wager < Number(promo.required_wager_amount || 0)) { + reasons.push(`Requires ${promo.required_wager_amount} SC wager in ${promo.required_wager_days} days.`); + } + } + if (promo.claim_limit && countPromoClaims(promo.promo_id) >= promo.claim_limit) reasons.push('Claim limit reached for this promo.'); + const recentClaim = promoManagerStore.claims + .filter((claim) => claim.promo_id === promo.promo_id && claim.user_id === userId && ['claimed', 'approved'].includes(claim.status)) + .sort((a, b) => Number(b.claimed_at || 0) - Number(a.claimed_at || 0))[0]; + if (promo.cooldown_hours > 0 && recentClaim) { + const remaining = (promo.cooldown_hours * 3600 * 1000) - (Date.now() - Number(recentClaim.claimed_at || 0)); + if (remaining > 0) reasons.push(`Cooldown active (${formatCooldown(remaining)} remaining).`); + } + if (reasons.length && logFailure) { + promoManagerStore.eligibilityFailures.push({ promo_id: promo.promo_id, user_id: userId, reasons, ts: Date.now() }); + if (promoManagerStore.eligibilityFailures.length > 3000) promoManagerStore.eligibilityFailures = promoManagerStore.eligibilityFailures.slice(-3000); + } + return { eligible: reasons.length === 0, reasons }; } -function promoCodeManagerText() { - const lines = promoCodeStore.codes - .slice() - .sort((a, b) => a.id - b.id) - .map((code) => `โ€ข ${formatPromoCodeLine(code)}`); +function renderPromoForUser(promo) { return [ - '๐Ÿงฉ *Promo Code Manager*', - '', - '*Global Rules:*', - 'โ€ข User must be registered under *GambleCodez* affiliate', - 'โ€ข 24h cooldown between any promo claims', + `๐ŸŽ *${promo.name}*`, + promo.description || '_No description provided._', '', - '*Configured Codes:*', - lines.length ? lines.join('\n') : 'No promo codes configured yet.', - '', - 'Use buttons below to add or toggle codes.', + `Code/Link: ${promo.code_or_link}`, + `Casino: ${promo.casino_base_url}`, + `Requirement: ${promo.requirement_type}`, + promo.claim_limit ? `Claim limit: ${promo.claim_limit}` : 'Claim limit: none', + promo.cooldown_hours ? `Cooldown: ${promo.cooldown_hours}h` : 'Cooldown: none', + `Approval: ${promo.auto_approve ? 'Automatic' : 'Admin review'}`, ].join('\n'); } +function getActivePromoCodeForUser(user) { + const promos = listActivePromos(); + const first = promos[0] || null; + if (!first) return null; + return { + id: first.promo_id, + code: first.code_or_link, + audience: first.new_user_only ? PROMO_AUDIENCE.NEW_USER : PROMO_AUDIENCE.EXISTING_USER, + requirementType: first.requirement_type === PROMO_REQUIREMENT.EXISTING_USER_WITH_WAGER ? PROMO_REQUIREMENT_TYPE.WAGER : PROMO_REQUIREMENT_TYPE.NO_REQUIREMENT, + }; +} + + + function wager30AdminKeyboard() { return Markup.inlineKeyboard([ [Markup.button.callback('๐Ÿ“‹ Open (Pending) Requests', 'w30_admin_pending')], @@ -1741,7 +1792,7 @@ function adminDashboardKeyboard(page = 1) { return Markup.inlineKeyboard([ [ Markup.button.callback('๐ŸŽ‰ Giveaway Tools', 'admin_cat_giveaway'), - Markup.button.callback('๐Ÿ“ฃ Promo Tools', 'admin_cat_promo'), + Markup.button.callback('๐ŸŽ› Promo Manager', 'admin_cat_promo'), ], [ Markup.button.callback('๐Ÿ‘ค User Tools', 'admin_cat_user'), @@ -1785,14 +1836,14 @@ function adminGiveawayToolsKeyboard() { function adminPromoToolsKeyboard() { return Markup.inlineKeyboard([ - [Markup.button.callback('๐Ÿ“„ View Promo', 'admin_view')], - [Markup.button.callback('๐Ÿงฉ Manage Promo Codes', 'admin_manage_promo_codes')], - [Markup.button.callback('โž• Add Promo Code', 'admin_promo_code_add')], - [Markup.button.callback('โœ๏ธ Edit Promo Code', 'admin_edit_code')], - [Markup.button.callback('๐Ÿ’ฐ Edit SC Amount', 'admin_edit_amount')], - [Markup.button.callback('๐Ÿ”ข Edit Claim Limit', 'admin_edit_limit')], - [Markup.button.callback('โธ Pause Promo', 'admin_pause'), Markup.button.callback('โ–ถ๏ธ Unpause', 'admin_unpause')], - [Markup.button.callback('๐Ÿ“ข Broadcast Update', 'admin_broadcast')], + [Markup.button.callback('๐ŸŽ› Promo Manager', 'admin_promo_manager')], + [Markup.button.callback('โž• Create Promo', 'admin_pm_create')], + [Markup.button.callback('โœ๏ธ Edit Promo', 'admin_pm_edit')], + [Markup.button.callback('โธ Pause/Unpause', 'admin_pm_pause_toggle')], + [Markup.button.callback('๐Ÿ—‘ Delete Promo', 'admin_pm_delete')], + [Markup.button.callback('๐Ÿ“Š Promo Stats', 'admin_pm_stats')], + [Markup.button.callback('๐Ÿ‘€ Preview Promo', 'admin_pm_preview')], + [Markup.button.callback('๐Ÿงพ Approval Queue', 'admin_pm_queue')], [Markup.button.callback('๐Ÿ“ฃ Announcements', 'admin_cmd_announce_start')], [Markup.button.callback('๐Ÿ’ก Tips Manager', 'admin_cmd_tips_dashboard')], [Markup.button.callback('โฌ…๏ธ Admin Dashboard', 'open_admin_dashboard')], @@ -2046,7 +2097,7 @@ function adminMainMenuKeyboard(user) { return Markup.inlineKeyboard([ [Markup.button.callback('๐Ÿ‘ค Users & Stats', 'pamenu_stats')], [Markup.button.callback('๐ŸŽ‰ Giveaways', 'pamenu_active_giveaways')], - [Markup.button.callback('๐Ÿ“ฃ Promo Tools', 'admin_cat_promo')], + [Markup.button.callback('๐ŸŽ› Promo Manager', 'admin_cat_promo')], [Markup.button.callback('โš™๏ธ System Tools', 'admin_cat_system')], [Markup.button.callback(toggleLabel, 'admin_cmd_mode_toggle')], [Markup.button.callback('โ†ฉ Back to User Menu', 'pamenu_back_user')], @@ -2611,7 +2662,7 @@ function buildDashboard() { 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 promoClaims = new Set(promoManagerStore.claims.filter((c) => ['claimed', 'approved'].includes(c.status)).map((c) => c.user_id)).size; const onboardingDone = allUsers.filter((u) => u.onboarding && u.onboarding.completedAt).length; const data = { generatedAt: new Date().toISOString(), @@ -2641,6 +2692,7 @@ function loadPersistentData() { Object.entries(promoHistory).forEach(([id, entries]) => promoHistoryStore.set(Number(id), entries)); Object.assign(analyticsStore, loadJson(analyticsFile, analyticsStore)); loadRuntimeState(); + loadPromoManagerDb(); loadHelpfulMessages(); loadSshvSessions(); } @@ -4131,30 +4183,13 @@ bot.command('discord', async (ctx) => { }); bot.command('promo', async (ctx) => { - const user = getUser(ctx); - const activeCode = getActivePromoCodeForUser(user); - if (!activeCode) { - await ctx.reply('No promo code is active for your account tier right now.'); - return; - } - const audienceLabel = activeCode.audience === PROMO_AUDIENCE.NEW_USER ? 'New User' : 'Existing User'; - const requirementLabel = activeCode.requirementType === PROMO_REQUIREMENT_TYPE.NO_REQUIREMENT ? 'No extra wager requirement' : 'Wager requirement applies'; - await ctx.reply( - `๐ŸŽ ${audienceLabel} Promo -Code: ${activeCode.code} -Amount: ${activeCode.amountSC} SC -Requirement: ${requirementLabel} - -${AFFILIATE_REMINDER_TEXT}`, - Markup.inlineKeyboard([ - [Markup.button.url('๐Ÿ”— Sign Up', LINKS.runewagerSignup)], - [Markup.button.url('๐Ÿ“‹ Enter Promo', LINKS.promoInput)], - [Markup.button.callback('โ“ How to Claim', 'menu_claim_bonus')], - ]), - ); + await ctx.reply('Open the Promo Menu to see DB-backed promos you are eligible for.', Markup.inlineKeyboard([[Markup.button.callback('๐ŸŽ Open Promo Menu', 'menu_claim_bonus')]])); }); bot.command('setpromo', async (ctx) => { + await ctx.reply('Legacy /setpromo is disabled. Use ๐ŸŽ› Promo Manager.'); + return; + if (!requireAdmin(ctx)) return; const user = getUser(ctx); clearPendingAction(user); @@ -4891,376 +4926,108 @@ bot.action('menu_claim_bonus', async (ctx) => { await linkedUsernameError(ctx); return; } - if (!promoStore.active) { - await ctx.reply('Promo claims are currently paused. Please check back soon.'); - return; - } - if (promoStore.remainingClaims <= 0) { - await ctx.reply('Promo claims are currently exhausted.'); - return; - } - - const activeCode = getActivePromoCodeForUser(user); - if (!activeCode) { - await ctx.reply(user.claimedPromo - ? 'No existing-user promo code is active yet. Please check back soon.' - : 'No new-user promo code is active yet. Please check back soon.'); - return; - } - - const cooldownRemaining = getPromoClaimCooldownRemainingMs(user); - if (cooldownRemaining > 0) { - await ctx.reply(`โณ Promo cooldown active. Please wait *${formatCooldown(cooldownRemaining)}* before claiming another code.`, { parse_mode: 'Markdown' }); - return; - } - - const audienceLabel = activeCode.audience === PROMO_AUDIENCE.NEW_USER ? 'NEW USER BONUS โ€” ONCE PER ACCOUNT/IP' : 'EXISTING USER BONUS CODE'; - const requirementLabel = activeCode.requirementType === PROMO_REQUIREMENT_TYPE.NO_REQUIREMENT - ? 'No additional wagering requirement for this code.' - : 'This code includes a wager requirement as listed in your account terms.'; - - await ctx.reply( - `๐ŸŽ ${audienceLabel} -Promo Code: ${activeCode.code} -Amount: ${activeCode.amountSC} SC each - -${AFFILIATE_REMINDER_TEXT} - -Requirement: ${requirementLabel} -Steps: -1) Stay registered under GambleCodez affiliate -2) Open the affiliate/promo page -3) Enter the promo code shown above`, - { - parse_mode: 'Markdown', - ...Markup.inlineKeyboard([ - [signupButton()], - [promoButton()], - [Markup.button.callback('โ„น๏ธ Tell me more about the 30 SC bonus', 'w30_bonus_info')], - [Markup.button.callback('โœ… I Entered Promo Code', 'promo_confirm_claimed')], - ]), - }, - ); - await sendPhoto(ctx, IMG_PROMO_ENTRY, '๐Ÿ“ธ Step 3: Enter the promo code here (Menu โ†’ Refer a Friend โ†’ Promo entry box)'); -}); - - -function applyPromoClaimIntent(user) { - const activeCode = getActivePromoCodeForUser(user); - if (!activeCode) { - return { ok: false, message: user.claimedPromo ? 'No existing-user promo codes are active right now.' : 'No new-user promo codes are active right now.' }; - } - if (activeCode.audience === PROMO_AUDIENCE.NEW_USER && user.claimedPromo) { - return { ok: false, message: 'You already claimed a new-user code. Only existing-user codes are now available.' }; - } - - const cooldownRemaining = getPromoClaimCooldownRemainingMs(user); - if (cooldownRemaining > 0) { - return { ok: false, message: `Promo cooldown active. Please wait ${formatCooldown(cooldownRemaining)} before claiming another code.` }; - } - - if (promoStore.remainingClaims <= 0 || !promoStore.active) { - return { ok: false, message: 'This promo is not available right now.' }; - } - - if (!promoStore.claimsByUser.has(user.id)) { - promoStore.claimsByUser.add(user.id); - promoStore.remainingClaims = Math.max(0, promoStore.remainingClaims - 1); - } - - user.claimedPromo = true; - if (!user.claimedPromoAt) user.claimedPromoAt = Date.now(); - user.lastAnyPromoClaimAt = Date.now(); - if (!Array.isArray(user.claimedPromoCodes)) user.claimedPromoCodes = []; - user.claimedPromoCodes.push({ - codeId: activeCode.id, - code: activeCode.code, - audience: activeCode.audience, - requirementType: activeCode.requirementType, - ts: Date.now(), - }); - if (user.claimedPromoCodes.length > 50) user.claimedPromoCodes = user.claimedPromoCodes.slice(-50); - - registerPromoClaim(user, activeCode.code || 'unknown'); - addBadge(user, 'promo_claimed'); - user.profileXP += 20; - trackAnalytics('promo_claimed', { - userId: user.id, - code: activeCode.code, - audience: activeCode.audience, - requirementType: activeCode.requirementType, - }); - adminLog('promo_claim_click', { - userId: user.id, - code: activeCode.code, - codeId: activeCode.id, - audience: activeCode.audience, - requirementType: activeCode.requirementType, - }); - trackOnboardingProgress(user); - return { ok: true, code: activeCode }; -} - - -bot.action('promo_confirm_claimed', async (ctx) => { - const user = getUser(ctx); - if (!consumeSmartButton(user.id, 'promo_confirm_claimed')) { - await ctx.answerCbQuery('Already processed'); - return; + const eligiblePromos = []; + for (const promo of listActivePromos()) { + // eslint-disable-next-line no-await-in-loop + const result = await evaluatePromoEligibility(user, promo, { logFailure: true }); + if (result.eligible) eligiblePromos.push(promo); } - const result = applyPromoClaimIntent(user); - if (!result.ok) { - await ctx.answerCbQuery(); - await ctx.reply(result.message); + if (!eligiblePromos.length) { + await ctx.reply('No eligible promos are available for your account right now.'); return; } - await ctx.answerCbQuery('Saved'); - await ctx.reply( - 'โœ… 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); + const rows = eligiblePromos.map((promo) => [Markup.button.callback(`๐ŸŽ ${promo.name}`, `promo_open_${promo.promo_id}`)]); + rows.push([Markup.button.callback('โฌ…๏ธ Main Menu', 'to_main_menu')]); + await ctx.reply(`๐ŸŽ *Promo Menu*\n\nOnly promos you are eligible for are shown below.`, { parse_mode: 'Markdown', ...Markup.inlineKeyboard(rows) }); }); -bot.action('promo_confirm_claimed_next', async (ctx) => { +bot.action(/promo_open_(\d+)/, async (ctx) => { const user = getUser(ctx); - if (!consumeSmartButton(user.id, 'promo_confirm_claimed_next')) { - await ctx.answerCbQuery('Already processed'); - return; - } - - const result = applyPromoClaimIntent(user); - if (!result.ok) { - await ctx.answerCbQuery(); - await ctx.reply(result.message); + const promoId = Number(ctx.match[1]); + const promo = getPromoById(promoId); + await ctx.answerCbQuery(); + if (!promo || promo.status !== PROMO_STATUS.ACTIVE) { + await ctx.reply('Promo not available.'); return; } + const eligibility = await evaluatePromoEligibility(user, promo, { logFailure: true }); + const buttons = [[Markup.button.callback('โฌ…๏ธ Promo Menu', 'menu_claim_bonus')]]; + if (eligibility.eligible) buttons.unshift([Markup.button.callback('โœ… Claim Promo', `promo_claim_${promo.promo_id}`)]); + const reasonText = eligibility.eligible ? 'You are eligible to claim this promo.' : `Not eligible: ${eligibility.reasons.join('; ')}`; + await ctx.reply(`${renderPromoForUser(promo)} - await ctx.answerCbQuery('Saved'); - await reactToMessage(ctx, '๐ŸŽ'); - const nextStep = getNextOnboardingStep(user); - if (nextStep >= 5) { - await ctx.reply('๐ŸŽ‰ Onboarding complete! Use /bonus to claim your 30 SC bonus.', Markup.inlineKeyboard([[Markup.button.callback('๐ŸŽฏ Claim 30 SC Bonus', 'w30_request_start')]])); - return; +${reasonText}`, { parse_mode: 'Markdown', ...Markup.inlineKeyboard(buttons) }); + if (promo.image_url && isValidHttpUrl(promo.image_url)) { + await sendPhoto(ctx, promo.image_url, `Preview image for ${promo.name}`); } - await showOnboardingPrompt(ctx, user, nextStep); }); -bot.action('w30_rules', async (ctx) => { - await ctx.answerCbQuery(); - await ctx.reply(WAGER_BONUS_RULES_TEXT, { - parse_mode: 'Markdown', - ...Markup.inlineKeyboard([ - [Markup.button.callback('๐ŸŽฏ Request My 30 SC Bonus', 'w30_request_start')], - [Markup.button.callback('โฌ…๏ธ Back', 'to_main_menu')], - ]), - }); -}); - -// "Tell me more about the 30 SC bonus" โ€” full explanation with affiliate reminder -bot.action('w30_bonus_info', async (ctx) => { - await ctx.answerCbQuery(); - const user = getUser(ctx); - const wb = user.wagerBonus || {}; - const attemptsLeft = 3 - (wb.attempts || 0); - const infoText = [ - '๐ŸŽฏ *GambleCodez 30 SC Bonus โ€” Full Information*', - '', - AFFILIATE_REMINDER_TEXT, - '', - '*Requirements:*', - 'โ€ข Must be under the *GambleCodez* affiliate', - 'โ€ข Must wager *3,000 SC* in any rolling 7-day period', - 'โ€ข Bonus is *once per account* (limit one per player)', - `โ€ข You may submit *up to 3 verification attempts* (you have *${attemptsLeft}* remaining)`, - '', - '*Process:*', - 'โ€ข Bonus: *30 SC*', - 'โ€ข Admin manually reviews and approves', - 'โ€ข Admin is pinged the moment you request verification', - 'โ€ข Admin sends the bonus directly on Runewager โ€” the bot does NOT send SC', - '', - 'โš ๏ธ Do not message admins repeatedly. Requests are reviewed in order.', - ].join('\n'); - await ctx.reply(infoText, { - parse_mode: 'Markdown', - ...Markup.inlineKeyboard([ - [Markup.button.callback('๐ŸŽฏ Request My 30 SC Bonus', 'w30_request_start')], - [Markup.button.callback('๐Ÿ”— Link Runewager Username', 'menu_link_runewager')], - [Markup.button.callback('โฌ…๏ธ Back to Menu', 'to_main_menu')], - ]), - }); -}); - -bot.action('w30_request_start', async (ctx) => { +bot.action(/promo_claim_(\d+)/, async (ctx) => { const user = getUser(ctx); - - if (!consumeSmartButton(user.id, 'w30_request_start')) { - await ctx.answerCbQuery('Already processed'); - return; - } - - clearPendingAction(user); - + const promoId = Number(ctx.match[1]); + const promo = getPromoById(promoId); await ctx.answerCbQuery(); - - // Run eligibility checks 1โ€“3 (not wager yet โ€” wager may need input) - const check = checkBonusEligibility(user); - - // Username not linked โ€” show /link instruction then resume the bonus flow - if (!check.ok && check.needsUsername) { - user.pendingAction = { type: 'await_runewager_username', returnTo: 'w30_bonus' }; - await ctx.reply( - '๐Ÿ”— *One quick step first โ€” link your Runewager username*\n\n' - + '*Use the command:*\n`/link YourUsername`\n' - + '*Example:* `/link GambleCodez`\n\n' - + `${AFFILIATE_REMINDER_TEXT}\n\n` - + 'โš ๏ธ You will be asked to confirm your username before it is saved.', - { - parse_mode: 'Markdown', - ...Markup.inlineKeyboard([ - [Markup.button.url('๐Ÿ‘ค Find My Username on Runewager', LINKS.runewagerProfile)], - [Markup.button.callback('โ„น๏ธ Tell me more about the 30 SC bonus', 'w30_bonus_info')], - [Markup.button.callback('โŒ Cancel', 'cancel_link')], - ]), - }, - ); + if (!promo || promo.status !== PROMO_STATUS.ACTIVE) { + await ctx.reply('Promo not available.'); return; } - - if (!check.ok && !check.needsWager) { - await ctx.reply(check.reason); + const eligibility = await evaluatePromoEligibility(user, promo, { logFailure: true }); + if (!eligibility.eligible) { + await ctx.reply(`Cannot claim: ${eligibility.reasons.join('; ')}`); return; } - - if (check.needsWager) { - // Ask user to declare their 7-day wager total (no screenshots; admin verifies on Runewager) - user.pendingAction = { type: 'w30_await_wager_total' }; - await ctx.reply( - 'What is your total SC wagered in the past 7 days?\n\n' - + 'Enter the number only (e.g. 3500). No screenshots needed โ€” admin verifies this on Runewager.', - ); - return; + const claim = { + claim_id: promoManagerStore.nextClaimId++, + promo_id: promo.promo_id, + user_id: user.id, + username: user.runewagerUsername || '', + status: promo.auto_approve ? 'claimed' : 'pending_approval', + claim_reason: promo.auto_approve ? 'auto_approved' : 'pending_admin_review', + claimed_at: Date.now(), + created_at: Date.now(), + updated_at: Date.now(), + }; + promoManagerStore.claims.push(claim); + user.claimedPromo = true; + user.lastAnyPromoClaimAt = Date.now(); + if (!Array.isArray(user.claimedPromoCodes)) user.claimedPromoCodes = []; + user.claimedPromoCodes.push({ code: promo.code_or_link, promoId: promo.promo_id, ts: Date.now() }); + if (promo.auto_approve) { + await ctx.reply('โœ… Promo claimed successfully.'); + } else { + await ctx.reply('๐Ÿงพ Claim submitted for admin approval. You will be notified once reviewed.'); + for (const adminId of ADMIN_IDS) { + bot.telegram.sendMessage(adminId, `New promo claim pending approval. Claim #${claim.claim_id} | Promo #${promo.promo_id} | User ${user.id}`).catch(() => {}); + } } - - await submitBonusRequest(ctx, user); }); -bot.action('menu_settings', async (ctx) => { - await ctx.answerCbQuery(); - await ctx.reply('Quick access links:', Markup.inlineKeyboard([ - [Markup.button.url('๐ŸŽฎ Open Mini App', LINKS.miniAppPlay)], - [Markup.button.url('๐Ÿ”— Sign Up (Browser)', LINKS.runewagerSignup)], - [Markup.button.url('๐Ÿ“‹ Promo Entry (Browser)', LINKS.promoInput)], - [Markup.button.url('๐Ÿ“˜ Runewager Discord', LINKS.rwDiscordJoin)], - [Markup.button.callback('โฌ…๏ธ Main Menu', 'to_main_menu')], - ])); -}); - -bot.action('menu_help', async (ctx) => { - const user = getUser(ctx); - await ctx.answerCbQuery(); - await sendHelpMenu(ctx, user, 'user'); -}); - -bot.action('open_help', async (ctx) => { - const user = getUser(ctx); - await ctx.answerCbQuery(); - await sendHelpMenu(ctx, user, 'user'); -}); - -bot.action('help_tab_user', async (ctx) => { - const user = getUser(ctx); - await ctx.answerCbQuery(); - await sendHelpMenu(ctx, user, 1); -}); - -bot.action('help_tab_admin', async (ctx) => { - if (!requireAdmin(ctx)) return; - const user = getUser(ctx); - await ctx.answerCbQuery(); - await sendHelpMenu(ctx, user, 6); -}); - -// โ”€โ”€ Paginated help booklet โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -bot.action(/^help_page_(\d+)$/, async (ctx) => { - const user = getUser(ctx); - await ctx.answerCbQuery(); - await sendHelpMenu(ctx, user, Number(ctx.match[1])); -}); - - - -// โ”€โ”€ Menu pagination โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -bot.action('menu_page_1', async (ctx) => { - const user = getUser(ctx); - await ctx.answerCbQuery(); - await sendPersistentUserMenu(ctx, user); -}); - -bot.action('menu_page_2', async (ctx) => { - const user = getUser(ctx); - await ctx.answerCbQuery(); - await sendMainMenu(ctx, user, 2); -}); - -// โ”€โ”€ Settings toggles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -bot.action('menu_settings_tab', async (ctx) => { - const user = getUser(ctx); - await ctx.answerCbQuery(); - await sendSettingsMenu(ctx, user); -}); - -bot.action('settings_toggle_playmode', async (ctx) => { - const user = getUser(ctx); - await ctx.answerCbQuery('Play mode updated!'); - user.settings.playMode = user.settings.playMode === 'browser' ? 'miniapp' : 'browser'; - await sendSettingsMenu(ctx, user); -}); - -bot.action('settings_toggle_quick_commands', async (ctx) => { - const user = getUser(ctx); - await ctx.answerCbQuery('Quick commands updated!'); - user.settings.showQuickCommands = !user.settings.showQuickCommands; - await sendSettingsMenu(ctx, user); -}); - -bot.action('settings_toggle_tooltips', async (ctx) => { - const user = getUser(ctx); - await ctx.answerCbQuery('Tooltips updated!'); - user.settings.tooltipsEnabled = !user.settings.tooltipsEnabled; - await sendSettingsMenu(ctx, user); -}); - -// โ”€โ”€ Quick-command bar shortcuts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -bot.action('menu_qc_play', async (ctx) => { - const user = getUser(ctx); - await ctx.answerCbQuery(); - const url = user.settings.playMode === 'browser' ? LINKS.runewagerSignup : LINKS.miniAppPlay; - await ctx.reply('๐ŸŽฎ Open Runewager:', Markup.inlineKeyboard([[Markup.button.url('Play Runewager', url)]])); -}); - -bot.action('menu_qc_profile', async (ctx) => { - const user = getUser(ctx); - await ctx.answerCbQuery(); - await ctx.reply('๐Ÿ‘ค Open your Runewager profile:', Markup.inlineKeyboard([[Markup.button.url('My Profile', LINKS.miniAppProfile)]])); -}); +bot.command('pmapprove', safeAdminHandler('pmapprove', { usage: '/pmapprove ' }, async (ctx) => { + const claimId = Number((ctx.message.text || '').split(/\s+/)[1]); + const claim = promoManagerStore.claims.find((entry) => entry.claim_id === claimId); + if (!claim || claim.status !== 'pending_approval') return ctx.reply('Pending claim not found.'); + claim.status = 'approved'; + claim.updated_at = Date.now(); + await ctx.reply(`โœ… Approved claim #${claimId}.`); + await bot.telegram.sendMessage(claim.user_id, `โœ… Your promo claim #${claimId} has been approved.`).catch(() => {}); +})); -bot.action('menu_qc_status', async (ctx) => { - const user = getUser(ctx); - await ctx.answerCbQuery(); - const statusLine = buildUserStatusLine(user); - await ctx.reply(`๐Ÿ“Š Your Status\n\n${statusLine}`, Markup.inlineKeyboard([[Markup.button.callback('โฌ…๏ธ Menu', 'to_main_menu')]])); -}); +bot.command('pmdeny', safeAdminHandler('pmdeny', { usage: '/pmdeny ' }, async (ctx) => { + const parts = (ctx.message.text || '').split(/\s+/); + const claimId = Number(parts[1]); + const reason = parts.slice(2).join(' ').trim() || 'No reason provided'; + const claim = promoManagerStore.claims.find((entry) => entry.claim_id === claimId); + if (!claim || claim.status !== 'pending_approval') return ctx.reply('Pending claim not found.'); + claim.status = 'denied'; + claim.claim_reason = reason; + claim.updated_at = Date.now(); + await ctx.reply(`โŒ Denied claim #${claimId}.`); + await bot.telegram.sendMessage(claim.user_id, `โŒ Your promo claim #${claimId} was denied. Reason: ${reason}`).catch(() => {}); +})); -// โ”€โ”€ Extra user menu shortcuts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ bot.action('menu_referral', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); @@ -5839,141 +5606,103 @@ bot.action(/^walk_(next|back|done)$/, async (ctx) => { // ========================= // Admin promo callbacks // ========================= -bot.action('admin_view', async (ctx) => { - if (!requireAdmin(ctx)) return; - await ctx.answerCbQuery(); - await ctx.reply(promoText()); -}); - -bot.action('admin_manage_promo_codes', async (ctx) => { - if (!requireAdmin(ctx)) return; - await ctx.answerCbQuery(); - await replaceCallbackPanel(ctx, promoCodeManagerText(), { - parse_mode: 'Markdown', - ...Markup.inlineKeyboard([ - [Markup.button.callback('โž• Add Promo Code', 'admin_promo_code_add')], - [Markup.button.callback('๐Ÿ” Toggle Promo Code', 'admin_promo_code_toggle_prompt')], - [Markup.button.callback('โฌ…๏ธ Promo Tools', 'admin_cat_promo')], - ]), +function promoManagerSummaryText() { + const promos = promoManagerStore.promos.slice().sort((a, b) => a.promo_id - b.promo_id); + const lines = promos.map((promo) => { + const claims = countPromoClaims(promo.promo_id); + return `โ€ข #${promo.promo_id} ${promo.name} โ€” ${promo.status} โ€” claims: ${claims}`; }); -}); + return ['๐ŸŽ› *Promo Manager*', '', 'All promo configuration now lives in the DB.', '', lines.length ? lines.join('\n') : '_No promos yet._'].join('\n'); +} -bot.action('admin_promo_code_add', async (ctx) => { +bot.action('admin_promo_manager', async (ctx) => { if (!requireAdmin(ctx)) return; - const user = getUser(ctx); - user.pendingAction = { type: 'admin_promo_code_add_input' }; await ctx.answerCbQuery(); - await ctx.reply(`Send new code as:\n||||\n\nExample:\nSPORTS5|existing_user|wager|5|Weekend reload`); + await replaceCallbackPanel(ctx, promoManagerSummaryText(), { parse_mode: 'Markdown', ...adminPromoToolsKeyboard() }); }); -bot.action('admin_promo_code_toggle_prompt', async (ctx) => { +bot.action('admin_view', async (ctx) => { if (!requireAdmin(ctx)) return; - const user = getUser(ctx); - user.pendingAction = { type: 'admin_promo_code_toggle_input' }; await ctx.answerCbQuery(); - await ctx.reply('Send the promo code ID to toggle active/paused state (example: 2).'); + await ctx.reply(promoManagerSummaryText(), { parse_mode: 'Markdown', ...adminPromoToolsKeyboard() }); }); -bot.action('admin_edit_code', async (ctx) => { +bot.action('admin_pm_create', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); - user.pendingAction = { type: 'admin_edit_code_input' }; + user.pendingAction = { type: 'admin_pm_create_name', data: {} }; await ctx.answerCbQuery(); - await ctx.reply('Enter the new promo code:'); + await ctx.reply('Promo creation step 1/9: Enter promo name.'); }); -bot.action('admin_edit_amount', async (ctx) => { +bot.action('admin_pm_edit', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); - user.pendingAction = { type: 'admin_edit_amount_input' }; + user.pendingAction = { type: 'admin_pm_edit_select_id' }; await ctx.answerCbQuery(); - await ctx.reply('Enter the new SC amount:'); + await ctx.reply('Send promo ID to edit.'); }); -bot.action('admin_edit_limit', async (ctx) => { +bot.action('admin_pm_pause_toggle', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); - user.pendingAction = { type: 'admin_edit_limit_input' }; + user.pendingAction = { type: 'admin_pm_pause_toggle_id' }; await ctx.answerCbQuery(); - await ctx.reply('Enter the new total claim limit:'); + await ctx.reply('Send promo ID to pause/unpause.'); }); -bot.action('admin_pause', async (ctx) => { +bot.action('admin_pm_delete', async (ctx) => { if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + user.pendingAction = { type: 'admin_pm_delete_id' }; await ctx.answerCbQuery(); - await replaceCallbackPanel(ctx, 'Pause the current promo?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Pause', 'admin_pause_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); + await ctx.reply('Send promo ID to mark deleted.'); }); -bot.action('admin_unpause', async (ctx) => { +bot.action('admin_pm_stats', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await replaceCallbackPanel(ctx, 'Unpause the promo?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Unpause', 'admin_unpause_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); + const lines = promoManagerStore.promos.map((promo) => { + const claims = countPromoClaims(promo.promo_id); + const uniqueUsers = new Set(promoManagerStore.claims.filter((c) => c.promo_id === promo.promo_id && ['claimed', 'approved'].includes(c.status)).map((c) => c.user_id)).size; + const failures = promoManagerStore.eligibilityFailures.filter((f) => f.promo_id === promo.promo_id).length; + return `#${promo.promo_id} ${promo.name} +Claims: ${claims} | Unique users: ${uniqueUsers} | Eligibility failures: ${failures}`; + }); + await ctx.reply(['๐Ÿ“Š Promo Stats', '', lines.length ? lines.join('\n\n') : 'No promos found.'].join('\n')); }); -bot.action('admin_remove', async (ctx) => { +bot.action('admin_pm_preview', async (ctx) => { if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + user.pendingAction = { type: 'admin_pm_preview_id' }; await ctx.answerCbQuery(); - await replaceCallbackPanel(ctx, 'Are you sure you want to remove the promo?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Remove', 'admin_remove_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); + await ctx.reply('Send promo ID to preview user-facing card.'); }); -bot.action('admin_broadcast', async (ctx) => { +bot.action('admin_pm_queue', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await replaceCallbackPanel(ctx, 'Send updated promo to all users?', Markup.inlineKeyboard([[Markup.button.callback('โœ… Confirm Broadcast', 'admin_broadcast_yes')], [Markup.button.callback('โŒ Cancel', 'admin_cancel')]])); -}); - -bot.action('admin_cancel', async (ctx) => { - if (!requireAdmin(ctx)) return; - await ctx.answerCbQuery('Cancelled'); - await replaceCallbackPanel(ctx, 'โœ… Cancelled. Select another promo action:', { parse_mode: 'Markdown', ...adminPromoToolsKeyboard() }); -}); - -bot.action('admin_pause_yes', async (ctx) => { - if (!requireAdmin(ctx)) return; - promoStore.active = false; - adminLog('pause_promo', { by: ctx.from.id }); - await ctx.answerCbQuery('Promo paused'); - await ctx.reply('Promo paused.'); + const pending = promoManagerStore.claims.filter((claim) => claim.status === 'pending_approval'); + const lines = pending.map((claim) => `Claim #${claim.claim_id} | Promo #${claim.promo_id} | User ${claim.user_id}`); + await ctx.reply(['๐Ÿงพ Pending Promo Queue', '', lines.length ? lines.join('\n') : 'No pending claims.', '', 'Use /pmapprove or /pmdeny .'].join('\n')); }); -bot.action('admin_unpause_yes', async (ctx) => { - if (!requireAdmin(ctx)) return; - promoStore.active = true; - adminLog('unpause_promo', { by: ctx.from.id }); - await ctx.answerCbQuery('Promo active'); - await ctx.reply('Promo is now active.'); -}); - -bot.action('admin_remove_yes', async (ctx) => { - if (!requireAdmin(ctx)) return; - promoStore.active = false; - promoStore.code = ''; - promoStore.amountSC = 0; - promoStore.totalClaimLimit = 0; - promoStore.remainingClaims = 0; - adminLog('remove_promo', { by: ctx.from.id }); - await ctx.answerCbQuery('Removed'); - await ctx.reply('Promo removed.'); -}); +bot.action('admin_pause', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); await ctx.reply('Use ๐ŸŽ› Promo Manager โ†’ Pause/Unpause.'); }); +bot.action('admin_unpause', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); await ctx.reply('Use ๐ŸŽ› Promo Manager โ†’ Pause/Unpause.'); }); +bot.action('admin_remove', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); await ctx.reply('Use ๐ŸŽ› Promo Manager โ†’ Delete Promo.'); }); +bot.action('admin_manage_promo_codes', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); await ctx.reply('Promo code manager is replaced by ๐ŸŽ› Promo Manager.'); }); +bot.action('admin_promo_code_add', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); await ctx.reply('Promo code manager is replaced by ๐ŸŽ› Promo Manager.'); }); +bot.action('admin_promo_code_toggle_prompt', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); await ctx.reply('Promo code manager is replaced by ๐ŸŽ› Promo Manager.'); }); +bot.action('admin_edit_code', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); await ctx.reply('Use ๐ŸŽ› Promo Manager โ†’ Edit Promo.'); }); +bot.action('admin_edit_amount', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); await ctx.reply('Use ๐ŸŽ› Promo Manager โ†’ Edit Promo.'); }); +bot.action('admin_edit_limit', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); await ctx.reply('Use ๐ŸŽ› Promo Manager โ†’ Edit Promo.'); }); bot.action('admin_broadcast_yes', async (ctx) => { if (!requireAdmin(ctx)) return; - let sent = 0; - for (const [, u] of userStore) { - if (u.optOutBroadcasts) continue; - try { - // eslint-disable-next-line no-await-in-loop - await bot.telegram.sendMessage(u.id, `๐Ÿ“ข Promo Update\n\n${promoText()}`); - sent += 1; - } catch (e) { - // ignore blocked users - } - } - adminLog('broadcast', { by: ctx.from.id, sent }); - await ctx.answerCbQuery('Broadcasted'); - await ctx.reply(`Promo update broadcasted to ${sent} users.`); + await ctx.answerCbQuery('Broadcast disabled'); + await ctx.reply('Legacy promo broadcasts are disabled. Use announcements and Promo Manager cards instead.'); }); - // ========================= // 30 SC Wager Bonus Admin callbacks // ========================= @@ -6602,6 +6331,156 @@ bot.on('text', async (ctx) => { return; } + if (action.type === 'admin_pm_create_name') { + if (!requireAdmin(ctx)) return; + action.data.name = text; + user.pendingAction = { type: 'admin_pm_create_code', data: action.data }; + await ctx.reply('Step 2/9: Enter promo code or promo link.'); + return; + } + if (action.type === 'admin_pm_create_code') { + if (!requireAdmin(ctx)) return; + action.data.code_or_link = text; + user.pendingAction = { type: 'admin_pm_create_domain', data: action.data }; + await ctx.reply('Step 3/9: Enter casino/affiliate base domain (example: runewager.com).'); + return; + } + if (action.type === 'admin_pm_create_domain') { + if (!requireAdmin(ctx)) return; + action.data.casino_base_url = text; + user.pendingAction = { type: 'admin_pm_create_description', data: action.data }; + await ctx.reply('Step 4/9: Enter promo description.'); + return; + } + if (action.type === 'admin_pm_create_description') { + if (!requireAdmin(ctx)) return; + action.data.description = text; + user.pendingAction = { type: 'admin_pm_create_image', data: action.data }; + await ctx.reply('Step 5/9: Enter promo image URL or type "skip".'); + return; + } + if (action.type === 'admin_pm_create_image') { + if (!requireAdmin(ctx)) return; + action.data.image_url = text.toLowerCase() === 'skip' ? '' : text; + user.pendingAction = { type: 'admin_pm_create_requirement', data: action.data }; + await ctx.reply(`Step 6/9: Requirement type? Reply with: +A = New User Only +B = Existing User +C = Existing User With Wager Requirement`); + return; + } + if (action.type === 'admin_pm_create_requirement') { + if (!requireAdmin(ctx)) return; + const choice = text.trim().toUpperCase(); + if (!['A', 'B', 'C'].includes(choice)) { await ctx.reply('Reply with A, B, or C.'); return; } + action.data.requirement_type = choice === 'A' ? PROMO_REQUIREMENT.NEW_USER_ONLY : (choice === 'B' ? PROMO_REQUIREMENT.EXISTING_USER : PROMO_REQUIREMENT.EXISTING_USER_WITH_WAGER); + if (choice === 'C') { + user.pendingAction = { type: 'admin_pm_create_required_wager_amount', data: action.data }; + await ctx.reply('Enter required_wager_amount (SC).'); + return; + } + action.data.required_wager_amount = 0; + action.data.required_wager_days = 0; + user.pendingAction = { type: 'admin_pm_create_claim_limit', data: action.data }; + await ctx.reply('Step 7/9: Claim limit (number) or "skip".'); + return; + } + if (action.type === 'admin_pm_create_required_wager_amount') { + if (!requireAdmin(ctx)) return; + action.data.required_wager_amount = Number(text) || 0; + user.pendingAction = { type: 'admin_pm_create_required_wager_days', data: action.data }; + await ctx.reply('Enter required_wager_days (rolling window).'); + return; + } + if (action.type === 'admin_pm_create_required_wager_days') { + if (!requireAdmin(ctx)) return; + action.data.required_wager_days = Number(text) || 0; + user.pendingAction = { type: 'admin_pm_create_claim_limit', data: action.data }; + await ctx.reply('Step 7/9: Claim limit (number) or "skip".'); + return; + } + if (action.type === 'admin_pm_create_claim_limit') { + if (!requireAdmin(ctx)) return; + action.data.claim_limit = text.toLowerCase() === 'skip' ? null : (Number(text) || null); + user.pendingAction = { type: 'admin_pm_create_cooldown', data: action.data }; + await ctx.reply('Step 8/9: Cooldown in hours (number) or "skip".'); + return; + } + if (action.type === 'admin_pm_create_cooldown') { + if (!requireAdmin(ctx)) return; + action.data.cooldown_hours = text.toLowerCase() === 'skip' ? 0 : (Number(text) || 0); + user.pendingAction = { type: 'admin_pm_create_auto_approve', data: action.data }; + await ctx.reply('Step 9/9: Auto-approve? Reply yes or no.'); + return; + } + if (action.type === 'admin_pm_create_auto_approve') { + if (!requireAdmin(ctx)) return; + const promo = normalizePromoManagerPromo({ + promo_id: promoManagerStore.nextPromoId++, + ...action.data, + auto_approve: /^y/i.test(text.trim()), + created_by: ctx.from.id, + created_at: Date.now(), + updated_at: Date.now(), + status: PROMO_STATUS.ACTIVE, + }); + promoManagerStore.promos.push(promo); + user.pendingAction = null; + await ctx.reply(`โœ… Promo created (#${promo.promo_id}): ${promo.name}`); + return; + } + if (action.type === 'admin_pm_edit_select_id') { + if (!requireAdmin(ctx)) return; + const promo = getPromoById(Number(text)); + if (!promo) { await ctx.reply('Promo ID not found.'); return; } + user.pendingAction = { type: 'admin_pm_edit_field', data: { promoId: promo.promo_id } }; + await ctx.reply('Send field=value to edit (name,code_or_link,casino_base_url,description,image_url,claim_limit,cooldown_hours,auto_approve,status).'); + return; + } + if (action.type === 'admin_pm_edit_field') { + if (!requireAdmin(ctx)) return; + const [field, ...rest] = text.split('='); + const promo = getPromoById(action.data.promoId); + if (!promo) { user.pendingAction = null; await ctx.reply('Promo not found.'); return; } + const valueRaw = rest.join('=').trim(); + if (!field || !valueRaw) { await ctx.reply('Use field=value format.'); return; } + const key = field.trim(); + if (!(key in promo)) { await ctx.reply('Invalid field.'); return; } + promo[key] = ['claim_limit', 'cooldown_hours', 'required_wager_amount', 'required_wager_days'].includes(key) ? Number(valueRaw) : (/^(true|false)$/i.test(valueRaw) ? /^true$/i.test(valueRaw) : valueRaw); + promo.updated_at = Date.now(); + user.pendingAction = null; + await ctx.reply(`โœ… Promo #${promo.promo_id} updated (${key}).`); + return; + } + if (action.type === 'admin_pm_pause_toggle_id') { + if (!requireAdmin(ctx)) return; + const promo = getPromoById(Number(text)); + if (!promo || promo.status === PROMO_STATUS.DELETED) { await ctx.reply('Promo ID not found.'); return; } + promo.status = promo.status === PROMO_STATUS.ACTIVE ? PROMO_STATUS.PAUSED : PROMO_STATUS.ACTIVE; + promo.updated_at = Date.now(); + user.pendingAction = null; + await ctx.reply(`โœ… Promo #${promo.promo_id} is now ${promo.status}.`); + return; + } + if (action.type === 'admin_pm_delete_id') { + if (!requireAdmin(ctx)) return; + const promo = getPromoById(Number(text)); + if (!promo) { await ctx.reply('Promo ID not found.'); return; } + promo.status = PROMO_STATUS.DELETED; + promo.updated_at = Date.now(); + user.pendingAction = null; + await ctx.reply(`๐Ÿ—‘ Promo #${promo.promo_id} marked deleted.`); + return; + } + if (action.type === 'admin_pm_preview_id') { + if (!requireAdmin(ctx)) return; + const promo = getPromoById(Number(text)); + if (!promo) { await ctx.reply('Promo ID not found.'); return; } + user.pendingAction = null; + await ctx.reply(renderPromoForUser(promo), { parse_mode: 'Markdown' }); + return; + } + // Admin promo edits if (action.type === 'admin_promo_code_add_input') { if (!requireAdmin(ctx)) return; @@ -9457,54 +9336,20 @@ bot.command('gwhistory', async (ctx) => { // โ”€โ”€ Feature 23: Promo Eligibility Check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ bot.command('promocheck', async (ctx) => { const user = getUser(ctx); - const activeCode = getActivePromoCodeForUser(user); - const issues = []; - if (!promoStore.active) issues.push('โŒ Promo claims are currently *inactive*.'); - if (promoStore.remainingClaims <= 0) issues.push('โŒ Promo claims are currently *exhausted*.'); - if (!activeCode) issues.push('โŒ No active promo code is configured for your account tier right now.'); - - const cooldownRemaining = getPromoClaimCooldownRemainingMs(user); - if (cooldownRemaining > 0) { - issues.push(`โณ Cooldown active โ€” *${formatCooldown(cooldownRemaining)}* remaining before any next code claim.`); + const promos = listActivePromos(); + if (!promos.length) { + await ctx.reply('No active promos are configured in Promo Manager.'); + return; } - - if (!issues.length && activeCode) { - const requirementLabel = activeCode.requirementType === PROMO_REQUIREMENT_TYPE.NO_REQUIREMENT ? 'No extra wager requirement' : 'Wager requirement applies'; - await ctx.reply( - `โœ… *You are eligible to claim a promo code!* - -Code: \`${activeCode.code}\` -Amount: *${activeCode.amountSC} SC* -Tier: *${activeCode.audience === PROMO_AUDIENCE.NEW_USER ? 'New User' : 'Existing User'}* -Requirement: *${requirementLabel}* - -Tap below to claim now.`, - { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('๐ŸŽ Claim Promo Now', 'menu_claim_bonus')]]) }, - ); - } else { - await ctx.reply(`๐Ÿ“‹ *Promo Eligibility Check* - -${issues.join('\n')} - -${AFFILIATE_REMINDER_TEXT}`, { - parse_mode: 'Markdown', - ...Markup.inlineKeyboard([[Markup.button.callback('๐Ÿ“ฉ Contact Support', 'menu_bugreport')]]), - }); + const lines = ['๐Ÿ“‹ *Promo Eligibility Check*', '']; + for (const promo of promos) { + // eslint-disable-next-line no-await-in-loop + const result = await evaluatePromoEligibility(user, promo, { logFailure: true }); + lines.push(`โ€ข ${promo.name}: ${result.eligible ? 'โœ… eligible' : `โŒ ${result.reasons.join('; ')}`}`); } + await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown' }); }); - -// โ”€โ”€ Feature 24: Language Info โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -bot.command('language', async (ctx) => { - const user = getUser(ctx); - if (!user.language && ctx.from.language_code) user.language = ctx.from.language_code; - await ctx.reply( - `๐ŸŒ *Language Settings*\n\nDetected language: \`${user.language || ctx.from.language_code || 'unknown'}\`\n\nThis bot currently operates in *English only*.\nYour language code has been recorded for future multi-language support.`, - { parse_mode: 'Markdown' }, - ); -}); - -// โ”€โ”€ Feature 25: DM-Only Support Portal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const SUPPORT_ISSUE_TYPES = ['Account not linked', 'Verification issue', 'Promo code problem', 'Giveaway issue', 'Bonus not received', 'Other']; bot.command('support', async (ctx) => {