From d9b9e90005ddeb782b83d675c100cca2904f838d Mon Sep 17 00:00:00 2001 From: GambleCodez Affiliates Date: Wed, 25 Feb 2026 04:44:37 -0600 Subject: [PATCH 1/3] Fix admin giveaway panel cleanup and action buttons --- .env.example | 2 +- .gitignore | 7 + index.js | 1740 +++++++++++++++++++++++++------------------- test/smoke.test.js | 57 ++ 4 files changed, 1068 insertions(+), 738 deletions(-) diff --git a/.env.example b/.env.example index 8da3d32..6cea3d8 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ ANNOUNCE_CHANNEL="-1002648883359" DEVICE=vps ADMIN_IDS=YOUR_TELEGRAM_USER_ID BOT_TOKEN= -WEBAPP_HMAC_KEY= +WEBAPP_HMAC_KEY= # 32-byte secret for HMAC-signing webapp payloads/CSRF tokens; generate with: openssl rand -hex 32 (or openssl rand -base64 32); keep secret and never commit real values TELEGRAM_BOT_TOKEN= DISCORD_CODE_GENERATION_IMAGE_URL=https://raw.githubusercontent.com/gamblecodezcom/Runewager/main/images/discord_code_generation.png DISCORD_VERIFY_IMAGE_URL=https://raw.githubusercontent.com/gamblecodezcom/Runewager/main/images/discord_verify.png diff --git a/.gitignore b/.gitignore index 19ef187..95cca36 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .env .env.* *.env +!.env.example +!.env.sample +!.env.template # Logs logs/ @@ -32,3 +35,7 @@ data/helpful_messages.json data/promo-history.json data/runtime-state.json data/sshv-sessions.json + +# Runtime generated data + backups +data/*.json +data/backups/** diff --git a/index.js b/index.js index 3215ada..d1760a0 100644 --- a/index.js +++ b/index.js @@ -34,8 +34,8 @@ const LINKS = { rwDiscordJoin: process.env.RW_DISCORD_JOIN || 'https://discord.gg/runewagers', rwDiscordLink: process.env.RW_DISCORD_LINK || 'https://discord.com/channels/1100486422395355197/1249181934811349052', // Telegram channels/groups — external links - gczChannel: 'https://t.me/GambleCodezDrops', - gczGroup: 'https://t.me/GambleCodezPrizeHub', + gczChannel: process.env.TELEGRAM_CHANNEL_LINK || 'https://t.me/GambleCodezDrops', + gczGroup: process.env.TELEGRAM_GROUP_LINK || 'https://t.me/GambleCodezPrizeHub', // Mini App links — always open INSIDE Telegram miniAppPlay: process.env.MINI_APP_PLAY_URL || 'https://t.me/RuneWager_bot/Play', miniAppProfile: process.env.MINI_APP_PROFILE_URL || 'https://t.me/RuneWager_bot/profile', @@ -43,12 +43,20 @@ 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'; +const TELEGRAM_CHANNEL_ID = process.env.TELEGRAM_CHANNEL_ID || process.env.ANNOUNCE_CHANNEL || ''; +const TELEGRAM_GROUP_ID = process.env.TELEGRAM_GROUP_ID || ''; +const ANNOUNCE_CHANNEL = TELEGRAM_CHANNEL_ID; -// Group username (or chat_id) for automatic silent tips -const TIPS_GROUP = process.env.TIPS_GROUP || '@GambleCodezPrizeHub'; +// Group chat_id for automatic Content Drops +const TIPS_GROUP = process.env.TIPS_GROUP || TELEGRAM_GROUP_ID || ''; // ── Images ───────────────────────────────────────────────────────────────── // Images live in /images/ next to index.js. @@ -148,47 +156,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(), @@ -226,6 +210,11 @@ const tipsStore = { lastSentTipId: null, }; +const broadcastConfigStore = { + targetChannel: ANNOUNCE_CHANNEL, + targetGroup: TIPS_GROUP, +}; + let tipsTimerRef = null; const dataDir = path.join(__dirname, 'data'); @@ -233,6 +222,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'); @@ -548,6 +538,10 @@ function createRuntimeStateSnapshot() { targetGroup: tipsStore.targetGroup, nextTipId: tipsStore.nextTipId, }, + broadcastConfigStore: { + targetChannel: broadcastConfigStore.targetChannel, + targetGroup: broadcastConfigStore.targetGroup, + }, }; } @@ -598,6 +592,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 +656,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); } @@ -700,11 +711,21 @@ function loadRuntimeState() { if (typeof raw.tipsStore.nextTipId === 'number') tipsStore.nextTipId = raw.tipsStore.nextTipId; } tipsStore.nextTipId = Math.max(tipsStore.nextTipId, tipsStore.tips.reduce((m, t) => Math.max(m, Number(t.id) || 0), 0) + 1); + if (raw.broadcastConfigStore) { + if (typeof raw.broadcastConfigStore.targetChannel === 'string' && raw.broadcastConfigStore.targetChannel.trim()) { + broadcastConfigStore.targetChannel = raw.broadcastConfigStore.targetChannel.trim(); + } + if (typeof raw.broadcastConfigStore.targetGroup === 'string' && raw.broadcastConfigStore.targetGroup.trim()) { + broadcastConfigStore.targetGroup = raw.broadcastConfigStore.targetGroup.trim(); + tipsStore.targetGroup = raw.broadcastConfigStore.targetGroup.trim(); + } + } } function persistRuntimeState() { const snapshot = createRuntimeStateSnapshot(); saveJson(runtimeStateFile, snapshot); + savePromoManagerDb(); lastPersistAt = Date.now(); } @@ -862,6 +883,7 @@ function getUser(ctx) { if (user.supportTicket === undefined) user.supportTicket = null; if (!user.claimedPromoCodes) user.claimedPromoCodes = []; if (!Array.isArray(user.claimedPromoCodes)) user.claimedPromoCodes = []; + if (user.hasClaimedNewUserPromo === undefined) user.hasClaimedNewUserPromo = false; if (!user.claimedPromoAt) user.claimedPromoAt = 0; if (!user.lastAnyPromoClaimAt) user.lastAnyPromoClaimAt = 0; if (user.claimedPromo && !user.claimedPromoAt) user.claimedPromoAt = Date.now(); @@ -1216,6 +1238,32 @@ function commandBlocked(commandText) { return blockedPatterns.some((re) => re.test(text)); } +function isHealthTlsEnabled() { + const tlsKeyPath = process.env.HTTPS_KEY_PATH; + const tlsCertPath = process.env.HTTPS_CERT_PATH; + if (!tlsKeyPath || !tlsCertPath) return false; + try { + validateSafePath(tlsKeyPath, PROJECT_DIR); + validateSafePath(tlsCertPath, PROJECT_DIR); + return true; + } catch (_) { + return false; + } +} + +function buildSshvSandboxRestrictionHint(err) { + const msg = String((err && (err.stderr || err.message)) || '').toLowerCase(); + const code = String((err && (err.code || err.errno)) || '').toUpperCase(); + const sandboxLike = code === 'EROFS' || code === 'EACCES' + || msg.includes('read-only file system') + || msg.includes('permission denied') + || msg.includes('operation not permitted') + || msg.includes('nonewprivileges') + || msg.includes('private tmp'); + if (!sandboxLike) return null; + return 'Sandbox restriction hit. This service runs with ProtectSystem/PrivateTmp/NoNewPrivileges. Write operations are typically limited to /var/www/html/Runewager/logs and /var/www/html/Runewager/data unless runewager.service ReadWritePaths is expanded.'; +} + async function executeSshvCommand(ctx, user, session, commandText) { const command = String(commandText || '').trim(); if (!command) { @@ -1257,7 +1305,7 @@ async function executeSshvCommand(ctx, user, session, commandText) { 'Reply with the full new file content, then tap Save or Cancel.', ].join('\n'), { - parse_mode: 'MarkdownV2', + parse_mode: 'Markdown', ...Markup.inlineKeyboard([ [Markup.button.callback('💾 Save', 'sshv_editor_save'), Markup.button.callback('❌ Cancel', 'sshv_editor_cancel')], ]), @@ -1320,6 +1368,10 @@ async function executeSshvCommand(ctx, user, session, commandText) { const child = exec(command, { cwd: session.cwd, timeout: 8000, maxBuffer: 2 * 1024 * 1024 }, async (err, stdout, stderr) => { const out = `${stdout || ''}${stderr || ''}`.trim(); session.buffer = out || (err ? err.message : '[no output]'); + const sandboxHint = buildSshvSandboxRestrictionHint(err); + if (sandboxHint) session.buffer = `${session.buffer} + +${sandboxHint}`; session.runningProcess = null; session.lastActivity = Date.now(); adminLog('sshv_command_finish', { adminId: user.id, command, error: err ? err.message : null }); @@ -1583,86 +1635,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 || user.hasClaimedNewUserPromo)) 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 +1843,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,16 +1887,16 @@ 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('💡 Content Drops', 'admin_cmd_tips_dashboard')], [Markup.button.callback('⬅️ Admin Dashboard', 'open_admin_dashboard')], ]); } @@ -1817,7 +1919,8 @@ function adminSystemToolsKeyboard(user = null) { [Markup.button.callback('📟 VPS Console (/sshv)', 'sshv_open')], [Markup.button.callback('🩺 Health Check', 'admin_cmd_health')], [Markup.button.callback('📦 Bot Version', 'admin_cmd_version')], - [Markup.button.callback('🧪 Tip Test (4h broadcast)', 'admin_cmd_tiptest')], + [Markup.button.callback('🤖 Verify Bot Setup', 'admin_cmd_verify_setup')], + [Markup.button.callback('🧪 Drop Test (4h broadcast)', 'admin_cmd_tiptest')], [Markup.button.callback('💾 Backup State', 'admin_backup_action')], [Markup.button.callback(toggleLabel, 'admin_cmd_mode_toggle')], [Markup.button.callback('⬅️ Admin Dashboard', 'open_admin_dashboard')], @@ -1971,18 +2074,19 @@ async function sendMainMenu(ctx, user, page = 1) { /** Keyboard for the new persistent USER MAIN MENU */ function userMainMenuKeyboard(isAdminUser) { const rows = [ + [Markup.button.url('🎮 Play Runewager', LINKS.miniAppPlay)], [ - Markup.button.url('🎮 Play Now', LINKS.miniAppPlay), - Markup.button.callback('🎁 Claim Bonus', 'pmenu_claim_bonus'), + Markup.button.callback('🎁 Bonuses & Rewards', 'pmenu_claim_bonus'), + Markup.button.callback('👤 Profile & Link', 'pmenu_my_profile'), ], [ - Markup.button.callback('📊 My Profile', 'pmenu_my_profile'), - Markup.button.callback('🏆 Giveaways', 'pmenu_giveaways'), + Markup.button.callback('🏆 Giveaways & Community', 'pmenu_giveaways'), + Markup.button.callback('⚙️ Settings', 'menu_settings_tab'), ], - [Markup.button.callback('❓ Help / Commands', 'pmenu_help')], + [Markup.button.callback('❓ Help Center / Commands', 'pmenu_help')], ]; if (isAdminUser) { - rows.push([Markup.button.callback('🛠 Admin Dashboard', 'admin_dashboard')]); + rows.push([Markup.button.callback('🛠 Admin Control Center', 'admin_dashboard')]); } return Markup.inlineKeyboard(rows); } @@ -1995,11 +2099,12 @@ function userMainMenuText(user) { `👋 Hey ${name}! Here's your Runewager menu.\n\n` + (statusLine ? `${statusLine}\n\n` : '') + `🌍 *Runewager is 100% FREE to play — worldwide access, no deposits.*\n\n` - + `🎮 *Play Now* — Open the Runewager Mini App\n` - + `🎁 *Claim Bonus* — 3.5 SC new-user bonus & 30 SC wager bonus\n` - + `📊 *My Profile* — Linked account, XP, badges & referrals\n` - + `🏆 *Giveaways* — Active SC giveaways & eligibility\n` - + `❓ *Help / Commands* — Command booklet & bug reports\n\n` + + `🎮 *Play Runewager* — Open the Runewager Mini App\n` + + `🎁 *Bonuses & Rewards* — Promo menu + 30 SC wager bonus\n` + + `👤 *Profile & Link* — Linked account, XP, badges & referrals\n` + + `🏆 *Giveaways & Community* — Active SC giveaways + join links\n` + + `⚙️ *Settings* — Play mode, quick commands, tooltips\n` + + `❓ *Help Center / Commands* — Guide, commands & bug reports\n\n` + `/help · /menu · /profile · /link · /bonus` ); } @@ -2044,10 +2149,18 @@ async function sendPersistentUserMenu(ctx, user) { function adminMainMenuKeyboard(user) { const toggleLabel = user && user.adminModeOn ? '🟢 Admin Mode: ON (tap to toggle)' : '⚪ Admin Mode: OFF (tap to toggle)'; 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('⚙️ System Tools', 'admin_cat_system')], + [ + Markup.button.callback('👥 User Ops & Stats', 'pamenu_stats'), + Markup.button.callback('🎉 Giveaway Ops', 'pamenu_active_giveaways'), + ], + [ + Markup.button.callback('🎛 Promo Manager', 'admin_cat_promo'), + Markup.button.callback('📣 Broadcasts & Drops', 'admin_cmd_announce_start'), + ], + [ + Markup.button.callback('⚙️ System & Health', 'admin_cat_system'), + Markup.button.callback('📄 Admin Help', 'help_tab_admin'), + ], [Markup.button.callback(toggleLabel, 'admin_cmd_mode_toggle')], [Markup.button.callback('↩ Back to User Menu', 'pamenu_back_user')], ]); @@ -2558,12 +2671,13 @@ function consumeSmartButton(userId, key) { } // Prune expired smart button entries every 10 minutes -setInterval(() => { +const smartButtonGcTimer = setInterval(() => { const now = Date.now(); for (const [token, expiry] of smartButtonTracker) { if (expiry <= now) smartButtonTracker.delete(token); } }, 10 * 60 * 1000); +smartButtonGcTimer.unref(); /** * Compute analytics for a given time window (milliseconds from now). @@ -2611,7 +2725,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 +2755,7 @@ function loadPersistentData() { Object.entries(promoHistory).forEach(([id, entries]) => promoHistoryStore.set(Number(id), entries)); Object.assign(analyticsStore, loadJson(analyticsFile, analyticsStore)); loadRuntimeState(); + loadPromoManagerDb(); loadHelpfulMessages(); loadSshvSessions(); } @@ -3196,14 +3311,14 @@ function buildHelpPages(user) { '/unapprove_group — Remove group from whitelist', '/list_groups — List all approved groups', '', - '💡 TIPS MANAGEMENT', - '/tips (or /t, /tp) — Post a tip to the group', + '💡 CONTENT DROPS MANAGEMENT', + '/tips (or /t, /tp) — Post a content drop to the group', '/tiplist — List all tips', '/tipadd — Add a new tip', '/tipremove — Remove a tip', '/tipedit — Edit an existing tip', '/tiptoggle — Enable/disable tips feature', - '/tipsettings — Configure tips settings', + '/tipsettings — Configure content drop settings', '', '🐞 BUG REPORTS', '/bugreports — View all open bug reports', @@ -3430,12 +3545,70 @@ bot.command('sshv', async (ctx) => { // → Send Channel Only → channel → state.none // ========================= +function parseModeLabel(mode) { + return mode === 'HTML' ? 'HTML' : 'Text'; +} + +function announceBuilderKeyboard(config) { + const dm = config.sendDm ? '✅ DM Users' : '☐ DM Users'; + const ch = config.sendChannel ? '✅ Channel' : '☐ Channel'; + const gr = config.sendGroup ? '✅ Group' : '☐ Group'; + return Markup.inlineKeyboard([ + [Markup.button.callback(dm, 'announce_toggle_dm'), Markup.button.callback(ch, 'announce_toggle_channel')], + [Markup.button.callback(gr, 'announce_toggle_group'), Markup.button.callback(`Mode: ${parseModeLabel(config.parseMode)}`, 'announce_toggle_mode')], + [Markup.button.callback('✏️ Edit Text', 'announce_edit')], + [Markup.button.callback('🚀 Send Broadcast Now', 'announce_send_now')], + [Markup.button.callback('❌ Cancel', 'admin_cancel')], + ]); +} + +async function sendAnnouncementTargets(ctx, announcementText, config) { + let sentDm = 0; + let failedDm = 0; + const sendOpts = config.parseMode === 'HTML' ? { parse_mode: 'HTML' } : {}; + + if (config.sendDm) { + for (const [, u] of userStore) { + if (u.optOutBroadcasts || u.muted) continue; + try { + // eslint-disable-next-line no-await-in-loop + await ctx.telegram.sendMessage(u.id, announcementText, sendOpts); + sentDm += 1; + } catch (_) { + failedDm += 1; + } + } + } + + let channelStatus = 'skipped'; + if (config.sendChannel && config.targetChannel) { + try { + await ctx.telegram.sendMessage(config.targetChannel, announcementText, sendOpts); + channelStatus = `sent (${config.targetChannel})`; + } catch (e) { + channelStatus = `failed (${e.message})`; + } + } + + let groupStatus = 'skipped'; + if (config.sendGroup && config.targetGroup) { + try { + await ctx.telegram.sendMessage(config.targetGroup, announcementText, sendOpts); + groupStatus = `sent (${config.targetGroup})`; + } catch (e) { + groupStatus = `failed (${e.message})`; + } + } + + return { sentDm, failedDm, channelStatus, groupStatus }; +} + async function handleAnnounceCommand(ctx) { if (!requireAdmin(ctx)) return; const user = getUser(ctx); clearPendingAction(user); user.pendingAction = { type: 'await_announcement_text' }; - await ctx.reply('Okay! What would you like to announce?\nYou can send normal text or HTML.'); + await ctx.reply('Okay! What would you like to announce?\nYou can send normal text or HTML. After sending text, choose targets (DM/channel/group).'); } bot.command('A', handleAnnounceCommand); @@ -3782,8 +3955,12 @@ bot.command('health', async (ctx) => { if (!requireAdmin(ctx)) return; try { const { data } = await new Promise((resolve, reject) => { - const opts = { hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 5000, rejectUnauthorized: false }; - const req = require('https').request(opts, (res) => { + const port = Number(process.env.PORT || 3000); + const useTls = isHealthTlsEnabled(); + const client = useTls ? require('https') : require('http'); + const opts = { hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 5000 }; + if (useTls) opts.rejectUnauthorized = false; + const req = client.request(opts, (res) => { let body = ''; res.on('data', (c) => { body += c; }); res.on('end', () => resolve({ status: res.statusCode, data: body })); @@ -4131,30 +4308,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); @@ -4301,7 +4461,7 @@ bot.action('pmenu_help', async (ctx) => { await ctx.answerCbQuery(); // Show help sub-menu: Command Help Booklet + Bug Report await replaceCallbackPanel(ctx, - '❓ *Help & Support*\n\nChoose what you need:', + '❓ *Help Center*\n\nChoose a help category:', { parse_mode: 'Markdown', ...Markup.inlineKeyboard([ @@ -4320,6 +4480,18 @@ bot.action('help_open_booklet', async (ctx) => { await sendHelpMenu(ctx, user, 1); }); +bot.action(/^help_page_(\d+)$/, async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + const page = Number(ctx.match[1]); + const pages = buildHelpPages(user); + const total = pages.length; + const safePage = Math.max(1, Math.min(total, page)); + const selected = pages[safePage - 1]; + await replaceCallbackPanel(ctx, selected.text, { parse_mode: 'Markdown', ...Markup.inlineKeyboard(selected.buttons) }); +}); + + bot.action('help_open_bugreport', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); @@ -4334,6 +4506,124 @@ bot.action('help_open_bugreport', async (ctx) => { ); }); + +// ── Legacy/shortcut callback aliases to keep menu flows deterministic ─────── +bot.action('menu_help', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + await sendHelpMenu(ctx, user, 1); +}); + +bot.action('open_help', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + await sendHelpMenu(ctx, user, 1); +}); + +bot.action('help_tab_admin', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + await sendHelpMenu(ctx, user, 6); +}); + +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(); + 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(); + user.settings.showQuickCommands = !user.settings.showQuickCommands; + await sendSettingsMenu(ctx, user); +}); + +bot.action('settings_toggle_tooltips', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + user.settings.tooltipsEnabled = !user.settings.tooltipsEnabled; + await sendSettingsMenu(ctx, user); +}); + +bot.action('menu_qc_play', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + await ctx.reply('🎮 Open Runewager:', Markup.inlineKeyboard([[miniAppPlayButton()], [Markup.button.callback('⬅️ Main Menu', 'to_main_menu')]])); +}); + +bot.action('menu_qc_profile', async (ctx) => { + await ctx.answerCbQuery(); + await ctx.reply('👤 Open your profile in the Runewager mini app:', Markup.inlineKeyboard([[Markup.button.url('👤 Open Profile', LINKS.miniAppProfile)], [Markup.button.callback('⬅️ Main Menu', 'to_main_menu')]])); +}); + +bot.action('menu_qc_status', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + await ctx.reply(buildUserStatusLine(user), Markup.inlineKeyboard([[Markup.button.callback('⬅️ Main Menu', 'to_main_menu')]])); +}); + +bot.action('w30_request_start', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + await showBonusStatus(ctx, user); +}); + +bot.action('w30_bonus_info', async (ctx) => { + await ctx.answerCbQuery(); + await ctx.reply( + `🎯 *30 SC Wager Bonus*\n\n• One-time bonus\n• Requires 3,000 SC wager in a rolling 7-day period\n• Manual admin review/credit`, + { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('🎯 Request Bonus', 'w30_request_start')], [Markup.button.callback('⬅️ Main Menu', 'to_main_menu')]]) }, + ); +}); + +bot.action('w30_rules', async (ctx) => { + await ctx.answerCbQuery(); + await ctx.reply( + `📋 *30 SC Bonus Rules*\n\n1) Link your Runewager username\n2) Stay under GambleCodez affiliate\n3) Wager at least 3,000 SC in 7-day rolling window\n4) Submit request via the bonus button\n5) Admin manually verifies and credits`, + { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('🎯 Request Bonus', 'w30_request_start')], [Markup.button.callback('⬅️ Main Menu', 'to_main_menu')]]) }, + ); +}); + +bot.action('admin_cmd_tips_dashboard', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await handleTipsCommand(ctx); +}); + +bot.action('admin_cmd_announce_start', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await handleAnnounceCommand(ctx); +}); + +bot.action('admin_cmd_tiptest', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await ctx.reply('Use /tiptest to send a test tip to the configured group.'); +}); + +bot.action('admin_broadcast', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await replaceCallbackPanel(ctx, 'Legacy promo broadcast is disabled. Use Announcements instead.', { + ...Markup.inlineKeyboard([[Markup.button.callback('📣 Announcements', 'admin_cmd_announce_start')], [Markup.button.callback('⬅️ Promo Tools', 'admin_cat_promo')]]), + }); +}); + +bot.action('admin_cancel', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery('Cancelled'); + await sendPersistentAdminMenu(ctx, getUser(ctx)); +}); + bot.action('pmenu_admin', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); @@ -4421,7 +4711,7 @@ bot.action('pamenu_active_giveaways', async (ctx) => { const running = Array.from(giveawayStore.running.values()); const text = buildActiveGiveawaysText(); const keyboard = activeGiveawaysKeyboard(running); - await ctx.reply(text, { parse_mode: 'Markdown', ...keyboard }); + await replaceCallbackPanel(ctx, text, { parse_mode: 'Markdown', ...keyboard }); }); bot.action('pamenu_tools', async (ctx) => { @@ -4482,6 +4772,11 @@ bot.action('pamenu_back_admin', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); if (!requireAdmin(ctx)) return; + try { + await ctx.deleteMessage(); + } catch (_) { + try { await ctx.editMessageReplyMarkup({ inline_keyboard: [] }); } catch (_) { /* ignore */ } + } await sendPersistentAdminMenu(ctx, user); }); @@ -4515,10 +4810,13 @@ bot.action('pamenu_tools_health', async (ctx) => { await ctx.answerCbQuery('Running health check...'); if (!requireAdmin(ctx)) return; try { - const httpsClient = require('https'); + const useTls = isHealthTlsEnabled(); + const httpClient = useTls ? require('https') : require('http'); const port = Number(process.env.PORT || 3000); const result = await new Promise((resolve, reject) => { - const req = httpsClient.get({ hostname: '127.0.0.1', port, path: '/health', rejectUnauthorized: false }, (res) => { + const opts = { hostname: '127.0.0.1', port, path: '/health' }; + if (useTls) opts.rejectUnauthorized = false; + const req = httpClient.get(opts, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => resolve(data)); @@ -4891,376 +5189,117 @@ 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 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 cooldownRemaining = getPromoClaimCooldownRemainingMs(user); - if (cooldownRemaining > 0) { - await ctx.reply(`⏳ Promo cooldown active. Please wait *${formatCooldown(cooldownRemaining)}* before claiming another code.`, { parse_mode: 'Markdown' }); + if (!eligiblePromos.length) { + await ctx.reply('No eligible promos are available for your account right now.'); 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.'; + const rows = eligiblePromos.map((promo) => [Markup.button.callback(`🎁 ${promo.name}`, `promo_open_${promo.promo_id}`)]); + rows.push([Markup.button.callback('✅ I claimed successfully', 'promo_user_claimed_successfully')]); + 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) }); +}); - await ctx.reply( - `🎁 ${audienceLabel} -Promo Code: ${activeCode.code} -Amount: ${activeCode.amountSC} SC each +bot.action('promo_user_claimed_successfully', async (ctx) => { + const user = getUser(ctx); + user.hasClaimedNewUserPromo = true; + user.lastAnyPromoClaimAt = Date.now(); + await ctx.answerCbQuery('Saved'); + await ctx.reply('✅ Got it — you are marked as already claimed. New-user-only promos are now hidden for your account.'); +}); -${AFFILIATE_REMINDER_TEXT} +bot.action(/promo_open_(\d+)/, async (ctx) => { + const user = getUser(ctx); + 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)} -Requirement: ${requirementLabel} +${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}`); + } +}); -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 result = applyPromoClaimIntent(user); - if (!result.ok) { - await ctx.answerCbQuery(); - await ctx.reply(result.message); - 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); -}); - -bot.action('promo_confirm_claimed_next', 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); - return; - } - - 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; - } - 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(); @@ -5381,9 +5420,29 @@ bot.action('admin_cmd_giveaway_status', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); const running = Array.from(giveawayStore.running.values()); - if (!running.length) { await ctx.reply('No giveaways currently running.'); return; } - const lines = running.map((g) => `#${g.id} — ${g.participants.size} entries — ends ${new Date(g.endTime).toUTCString()}`); - await ctx.reply(`📊 Running Giveaways (${running.length}):\n\n${lines.join('\n')}`); + if (!running.length) { + await replaceCallbackPanel(ctx, `🎁 *Active Giveaways*\n\nNo giveaways are currently running.`, { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([[Markup.button.callback('⬅️ Return to Admin Menu', 'pamenu_back_admin')]]), + }); + return; + } + const lines = running.map((g) => { + const remainSec = Math.max(0, Math.floor((g.endTime - Date.now()) / 1000)); + const remain = remainSec > 3600 + ? `${Math.floor(remainSec / 3600)}h ${Math.floor((remainSec % 3600) / 60)}m` + : `${Math.floor(remainSec / 60)}m ${remainSec % 60}s`; + return `#${g.id} — ${g.participants.size} entries — ⏱ ${remain} remaining`; + }); + const actionRows = running.slice(0, 5).map((g) => ([ + Markup.button.callback(`🎉 Join #${g.id}`, `gw_join_${g.id}`), + Markup.button.callback(`📋 Details #${g.id}`, `gw_details_${g.id}`), + ])); + actionRows.push([Markup.button.callback('⬅️ Return to Admin Menu', 'pamenu_back_admin')]); + await replaceCallbackPanel(ctx, `📊 *Running Giveaways* (${running.length})\n\n${lines.join('\\n')}`, { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard(actionRows), + }); }); bot.action('admin_cmd_testgiveaway', async (ctx) => { @@ -5407,7 +5466,29 @@ bot.action('admin_cmd_health', async (ctx) => { bot.action('admin_cmd_version', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('Use /version to view bot version info.'); + const commitHash = process.env.COMMIT_HASH || 'local'; + const deployTime = process.env.DEPLOY_TIME || 'unknown'; + const uptime = Math.floor(process.uptime()); + const uptimeStr = `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m ${uptime % 60}s`; + await replaceCallbackPanel(ctx, + `📦 *Bot Version Info* + +Version: \`${pkgVersion}\` +Commit: \`${commitHash}\` +Deployed: ${deployTime} +Node: \`${process.version}\` +Uptime: ${uptimeStr}`, + { parse_mode: 'Markdown', ...Markup.inlineKeyboard([ + [Markup.button.callback('⚙️ System Tools', 'admin_cat_system')], + [Markup.button.callback('⬅️ Admin Dashboard', 'open_admin_dashboard')], + ]) }, + ); +}); + +bot.action('admin_cmd_verify_setup', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await ctx.reply('Use /verify_bot_setup to validate BotFather setup, permissions, and targets.'); }); bot.action('admin_backup_action', async (ctx) => { @@ -5525,8 +5606,10 @@ bot.action('sshv_ctrl_c', async (ctx) => { if (session.runningProcess && !session.runningProcess.killed) { session.runningProcess.kill('SIGINT'); session.buffer = 'SIGINT sent to running process.'; + adminLog('sshv_ctrl_c', { adminId: user.id, outcome: 'sent', cwd: session.cwd, command: session.lastCommand || '' }); await ctx.answerCbQuery('SIGINT sent'); } else { + adminLog('sshv_ctrl_c', { adminId: user.id, outcome: 'no_running_process', cwd: session.cwd, command: session.lastCommand || '' }); await ctx.answerCbQuery('No running process.'); } persistSshvSessions(); @@ -5541,8 +5624,10 @@ bot.action('sshv_ctrl_z', async (ctx) => { if (session.runningProcess && !session.runningProcess.killed) { try { session.runningProcess.kill('SIGTSTP'); } catch (_) { session.runningProcess.kill('SIGTERM'); } session.buffer = 'Stop signal sent to running process.'; + adminLog('sshv_ctrl_z', { adminId: user.id, outcome: 'sent', cwd: session.cwd, command: session.lastCommand || '' }); await ctx.answerCbQuery('Stop sent'); } else { + adminLog('sshv_ctrl_z', { adminId: user.id, outcome: 'no_running_process', cwd: session.cwd, command: session.lastCommand || '' }); await ctx.answerCbQuery('No running process.'); } persistSshvSessions(); @@ -5618,14 +5703,23 @@ bot.action('sshv_editor_save', async (ctx) => { await ctx.answerCbQuery('Send edited file content first.'); return; } - fs.writeFileSync(session.editorMode.filePath, draft, 'utf8'); - adminLog('sshv_editor_save', { adminId: user.id, filePath: session.editorMode.filePath, size: draft.length }); - session.buffer = `Saved ${session.editorMode.filePath}`; - session.editorMode = null; - user.pendingAction = { type: 'await_sshv_command' }; - persistSshvSessions(); - await ctx.answerCbQuery('Saved'); - await renderSshvConsole(ctx, session); + try { + fs.writeFileSync(session.editorMode.filePath, draft, 'utf8'); + adminLog('sshv_editor_save', { adminId: user.id, filePath: session.editorMode.filePath, size: draft.length }); + session.buffer = `Saved ${session.editorMode.filePath}`; + session.editorMode = null; + user.pendingAction = { type: 'await_sshv_command' }; + persistSshvSessions(); + await ctx.answerCbQuery('Saved'); + await renderSshvConsole(ctx, session); + } catch (e) { + adminLog('sshv_editor_save_error', { adminId: user.id, filePath: session.editorMode.filePath, error: e.message }); + session.buffer = `Failed to save ${session.editorMode.filePath}: ${e.message}`; + user.pendingAction = { type: 'await_sshv_editor_content' }; + persistSshvSessions(); + await ctx.answerCbQuery('Save failed'); + await renderSshvConsole(ctx, session, 'Save failed. You can edit and retry.'); + } }); bot.action('sshv_editor_cancel', async (ctx) => { @@ -5633,6 +5727,7 @@ bot.action('sshv_editor_cancel', async (ctx) => { const user = getUser(ctx); const session = getSshvSession(user.id, { createIfMissing: true }); session.editorMode = null; + adminLog('sshv_editor_cancel', { adminId: user.id }); user.pendingAction = { type: 'await_sshv_command' }; persistSshvSessions(); session.buffer = 'Editor cancelled.'; @@ -5839,141 +5934,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(); + await handleAnnounceCommand(ctx); }); - // ========================= // 30 SC Wager Bonus Admin callbacks // ========================= @@ -6456,9 +6513,9 @@ bot.on('text', async (ctx) => { session.lastActivity = Date.now(); persistSshvSessions(); await ctx.reply( - `📝 Draft captured for \`${escapeMarkdownFull(session.editorMode.filePath)}\`. Save changes?`, + `📝 Draft captured for \`${escapeMarkdownV2(session.editorMode.filePath)}\`. Save changes?`, { - parse_mode: 'MarkdownV2', + parse_mode: 'Markdown', ...Markup.inlineKeyboard([ [Markup.button.callback('💾 Save', 'sshv_editor_save'), Markup.button.callback('❌ Cancel', 'sshv_editor_cancel')], ]), @@ -6467,6 +6524,29 @@ bot.on('text', async (ctx) => { return; } + if (action.type === 'await_register_chat_forward') { + if (!requireAdmin(ctx)) return; + const msg = ctx.message || {}; + const fwdChat = msg.forward_from_chat + || (msg.forward_origin && msg.forward_origin.chat) + || (msg.sender_chat ? msg.sender_chat : null); + if (!fwdChat || !fwdChat.id) { + await ctx.reply('Please forward a message from the target channel/group.'); + return; + } + const chatId = Number(fwdChat.id); + approvedGroupsStore.add(chatId); + tipsStore.targetGroup = String(chatId); + broadcastConfigStore.targetGroup = String(chatId); + user.pendingAction = null; + persistRuntimeState(); + await ctx.reply(`✅ Registered chat for broadcasts/tips: +• ${fwdChat.title || chatId} (${chatId}) + +Tips and group broadcasts will now use this target.`); + return; + } + // Admin prompt: whois lookup if (action.type === 'await_admin_whois') { if (!requireAdmin(ctx)) return; @@ -6602,6 +6682,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; @@ -6954,7 +7184,7 @@ bot.on('text', async (ctx) => { if (action.type === 'await_tip_add_text') { if (!requireAdmin(ctx)) return; if (!text) { - await ctx.reply('Tip text cannot be empty. Please send the tip you want to add.'); + await ctx.reply('Drop text cannot be empty. Please send the tip you want to add.'); return; } const newTip = { id: tipsStore.nextTipId, text, enabled: true }; @@ -6963,7 +7193,7 @@ bot.on('text', async (ctx) => { user.pendingAction = null; persistRuntimeState(); saveHelpfulMessages(); - await ctx.reply(`Added as Tip #${newTip.id}.`); + await ctx.reply(`Added as Drop #${newTip.id}.`); return; } @@ -6971,7 +7201,7 @@ bot.on('text', async (ctx) => { if (action.type === 'await_tip_edit_text') { if (!requireAdmin(ctx)) return; if (!text) { - await ctx.reply('Tip text cannot be empty. Please send the updated tip text.'); + await ctx.reply('Drop text cannot be empty. Please send the updated tip text.'); return; } const tipId = action.data && action.data.tipId; @@ -6985,7 +7215,7 @@ bot.on('text', async (ctx) => { user.pendingAction = null; persistRuntimeState(); saveHelpfulMessages(); - await ctx.reply(`Tip #${tipId} updated.`); + await ctx.reply(`Drop #${tipId} updated.`); return; } @@ -7133,64 +7363,121 @@ bot.action('announce_edit', async (ctx) => { await ctx.reply('Sure — send the updated announcement text.'); }); -bot.action('announce_send_all', async (ctx) => { +bot.action('announce_toggle_dm', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); - await ctx.answerCbQuery('Sending...'); const action = user.pendingAction; - if (!action || action.type !== 'await_announcement_action' || !action.data) { - await ctx.reply('No announcement pending. Use /announce to start a new one.'); - return; - } - const announcementText = action.data.announcementText; - user.pendingAction = null; + await ctx.answerCbQuery(); + if (!action || action.type !== 'await_announcement_action' || !action.data) return; + action.data.sendDm = !action.data.sendDm; + await replaceCallbackPanel(ctx, '📣 Broadcast targets updated.', announceBuilderKeyboard(action.data)); +}); - // Broadcast to all non-opted-out users - let sent = 0; - for (const [, u] of userStore) { - if (u.optOutBroadcasts || u.muted) continue; - try { - await ctx.telegram.sendMessage(u.id, announcementText); - sent += 1; - } catch (_) { /* ignore blocked/deactivated users */ } - } +bot.action('announce_toggle_channel', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const action = user.pendingAction; + await ctx.answerCbQuery(); + if (!action || action.type !== 'await_announcement_action' || !action.data) return; + action.data.sendChannel = !action.data.sendChannel; + await replaceCallbackPanel(ctx, '📣 Broadcast targets updated.', announceBuilderKeyboard(action.data)); +}); - // Send to the GambleCodezDrops channel - try { - await ctx.telegram.sendMessage(ANNOUNCE_CHANNEL, announcementText); - } catch (e) { - await ctx.reply(`⚠️ Channel send failed: ${e.message}`); - } +bot.action('announce_toggle_group', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const action = user.pendingAction; + await ctx.answerCbQuery(); + if (!action || action.type !== 'await_announcement_action' || !action.data) return; + action.data.sendGroup = !action.data.sendGroup; + await replaceCallbackPanel(ctx, '📣 Broadcast targets updated.', announceBuilderKeyboard(action.data)); +}); - adminLog('announce_all', { by: ctx.from.id, sent }); - await ctx.reply(`📣 Announcement sent to ${sent} users and the GambleCodezDrops channel.`); +bot.action('announce_toggle_mode', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const action = user.pendingAction; + await ctx.answerCbQuery(); + if (!action || action.type !== 'await_announcement_action' || !action.data) return; + action.data.parseMode = action.data.parseMode === 'HTML' ? 'TEXT' : 'HTML'; + await replaceCallbackPanel(ctx, `📣 Mode switched to ${parseModeLabel(action.data.parseMode)}.`, announceBuilderKeyboard(action.data)); }); -bot.action('announce_send_channel', async (ctx) => { +bot.action('announce_send_now', async (ctx) => { if (!requireAdmin(ctx)) return; const user = getUser(ctx); - await ctx.answerCbQuery('Sending to channel...'); const action = user.pendingAction; + await ctx.answerCbQuery('Sending...'); if (!action || action.type !== 'await_announcement_action' || !action.data) { await ctx.reply('No announcement pending. Use /announce to start a new one.'); return; } - const announcementText = action.data.announcementText; + const result = await sendAnnouncementTargets(ctx, action.data.announcementText, action.data); + adminLog('announce_send_now', { by: ctx.from.id, ...result, mode: action.data.parseMode, targets: action.data }); user.pendingAction = null; + await ctx.reply( + `✅ Broadcast complete.\n• DM users sent: ${result.sentDm}\n• DM failed: ${result.failedDm}\n• Channel: ${result.channelStatus}\n• Group: ${result.groupStatus}`, + ); +}); - try { - await ctx.telegram.sendMessage(ANNOUNCE_CHANNEL, announcementText); - adminLog('announce_channel', { by: ctx.from.id }); - await ctx.reply('📣 Announcement sent to the GambleCodezDrops channel.'); - } catch (e) { - await ctx.reply(`❌ Failed to send to channel: ${e.message}`); +// Legacy aliases +bot.action('announce_send_all', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const action = user.pendingAction; + if (action && action.type === 'await_announcement_action' && action.data) { + action.data.sendDm = true; + action.data.sendChannel = true; + action.data.sendGroup = false; } + await ctx.answerCbQuery('Using new broadcast flow'); + await ctx.reply('Legacy "Send All" mapped to the new broadcast flow. Tap "Send Broadcast Now" after reviewing toggles.'); +}); + +bot.action('announce_send_channel', async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + const action = user.pendingAction; + if (action && action.type === 'await_announcement_action' && action.data) { + action.data.sendDm = false; + action.data.sendChannel = true; + action.data.sendGroup = false; + } + await ctx.answerCbQuery('Using new broadcast flow'); + await ctx.reply('Legacy "Send Channel Only" mapped to new broadcast flow. Tap "Send Broadcast Now" to continue.'); }); // ========================= -// Tips System — scheduler + admin commands + callbacks +// Content Drops System — scheduler + admin commands + callbacks // ========================= +async function resolveTipTargetChatId(rawTarget) { + const t = String(rawTarget || '').trim(); + if (!t) return null; + if (/^-?\d+$/.test(t)) return Number(t); + if (t.startsWith('@')) return t; + return `@${t}`; +} + +async function postTipToConfiguredTarget(tip, telegram) { + const primaryTarget = await resolveTipTargetChatId(tipsStore.targetGroup || broadcastConfigStore.targetGroup); + const fallbackTarget = approvedGroupsStore.size ? Array.from(approvedGroupsStore)[0] : null; + const candidates = [primaryTarget, fallbackTarget].filter(Boolean); + let lastErr = null; + for (const target of candidates) { + try { + // eslint-disable-next-line no-await-in-loop + await telegram.sendMessage(target, formatTipForHtml(tip.text), { parse_mode: 'HTML', disable_notification: true }); + tipsStore.targetGroup = String(target); + broadcastConfigStore.targetGroup = String(target); + return { ok: true, target }; + } catch (e) { + lastErr = e; + } + } + return { ok: false, error: lastErr }; +} + /** * Start (or restart) the tips scheduler. * Clears any existing timer then arms a fresh one using the current interval. @@ -7207,27 +7494,20 @@ function startTipsScheduler() { : enabled; const tip = pool[Math.floor(Math.random() * pool.length)]; try { - const me = await bot.telegram.getMe(); - const member = await bot.telegram.getChatMember(tipsStore.targetGroup, me.id); - const canSend = member - && (member.status === 'administrator' || member.status === 'creator' || member.can_send_messages); - if (!canSend) { - logEvent('warn', 'Skipping helpful message: missing permission in target group', { target: tipsStore.targetGroup }); + const sendResult = await postTipToConfiguredTarget(tip, bot.telegram); + if (!sendResult.ok) { + logEvent('warn', 'Failed to post group tip', { tipId: tip.id, error: sendResult.error ? sendResult.error.message : 'unknown' }); return; } - await bot.telegram.sendMessage(tipsStore.targetGroup, tip.text, { - parse_mode: 'Markdown', - disable_notification: true, - }); tipsStore.lastSentTipId = tip.id; - logEvent('info', 'Group tip posted', { tipId: tip.id }); + logEvent('info', 'Group tip posted', { tipId: tip.id, target: sendResult.target }); } catch (e) { logEvent('warn', 'Failed to post group tip', { tipId: tip.id, error: e.message }); } }, ms); } -/** Build the Tips Manager dashboard keyboard */ +/** Build the Content Drops Manager dashboard keyboard */ function tipsDashboardKeyboard() { return Markup.inlineKeyboard([ [Markup.button.callback('➕ Add Tip', 'tips_cmd_add'), Markup.button.callback('✏️ Edit Tip', 'tips_cmd_edit')], @@ -7252,12 +7532,12 @@ function tipSelectKeyboard(action) { return Markup.inlineKeyboard(rows); } -/** Send (or re-send) the Tips Manager dashboard */ +/** Send (or re-send) the Content Drops Manager dashboard */ async function sendTipsDashboard(ctx) { const total = tipsStore.tips.length; const enabled = tipsStore.tips.filter((t) => t.enabled).length; const status = tipsStore.systemEnabled ? '🟢 Enabled' : '🔴 Disabled'; - const text = `📝 *Tips Manager*\n\nStatus: ${status}\nTotal Tips: ${total} (${enabled} active)\nInterval: every ${tipsStore.intervalHours}h\nTarget: ${tipsStore.targetGroup}`; + const text = `📝 *Content Drops Manager*\n\nStatus: ${status}\nTotal Drops: ${total} (${enabled} active)\nInterval: every ${tipsStore.intervalHours}h\nTarget: ${tipsStore.targetGroup}`; await replaceCallbackPanel(ctx, text, { parse_mode: 'Markdown', ...tipsDashboardKeyboard() }); } @@ -7274,7 +7554,7 @@ bot.command('tp', handleTipsCommand); bot.command('tiplist', async (ctx) => { if (!requireAdmin(ctx)) return; - const lines = ['📋 *Current Tips:*\n']; + const lines = ['📋 *Current Content Drops:*\n']; for (const [idx, tip] of tipsStore.tips.entries()) { const preview = tip.text.replace(/\*/g, '').slice(0, 80); lines.push(`${idx + 1}. #${tip.id} ${tip.enabled ? '✅' : '🔇'} ${preview}${tip.text.length > 80 ? '…' : ''}`); @@ -7351,7 +7631,7 @@ bot.command('tiptoggle', async (ctx) => { tipsStore.systemEnabled = !tipsStore.systemEnabled; persistRuntimeState(); saveHelpfulMessages(); - const status = tipsStore.systemEnabled ? '🟢 Tips System Enabled' : '🔴 Tips System Disabled'; + const status = tipsStore.systemEnabled ? '🟢 Content Drops System Enabled' : '🔴 Content Drops System Disabled'; await ctx.reply(status); }); @@ -7363,23 +7643,21 @@ bot.command('tiptest', async (ctx) => { ? enabled.filter((t) => t.id !== tipsStore.lastSentTipId) : enabled; const tip = pool[Math.floor(Math.random() * pool.length)]; - try { - await bot.telegram.sendMessage(tipsStore.targetGroup, formatTipForHtml(tip.text), { - parse_mode: 'HTML', - disable_notification: true, - }); + const sendResult = await postTipToConfiguredTarget(tip, bot.telegram); + if (sendResult.ok) { tipsStore.lastSentTipId = tip.id; persistRuntimeState(); saveHelpfulMessages(); - await ctx.reply(`✅ Test tip posted to ${tipsStore.targetGroup} (silent).`); - } catch (e) { - await ctx.reply(`❌ Failed to post test tip to ${tipsStore.targetGroup}: ${e.message}`); + await ctx.reply(`✅ Test tip posted to ${sendResult.target} (silent).`); + } else { + await ctx.reply(`❌ Failed to post test tip. ${sendResult.error ? sendResult.error.message : 'Unknown error'} +Tip: run /register_chat and forward a message from the target group/channel.`); } }); bot.command('tipsettings', async (ctx) => { if (!requireAdmin(ctx)) return; - const text = `⚙️ *Tips Settings*\n\nInterval: every ${tipsStore.intervalHours} hours\nSilent mode: Always ON\nTarget group: ${tipsStore.targetGroup}\n\nTo change the interval, reply with the number of hours (e.g. \`4\`).`; + const text = `⚙️ *Content Drops Settings*\n\nInterval: every ${tipsStore.intervalHours} hours\nSilent mode: Always ON\nTarget group: ${tipsStore.targetGroup}\n\nTo change the interval, reply with the number of hours (e.g. \`4\`).`; const user = getUser(ctx); clearPendingAction(user); user.pendingAction = { type: 'await_tip_settings_interval' }; @@ -7417,7 +7695,7 @@ bot.action('tips_cmd_toggle', async (ctx) => { tipsStore.systemEnabled = !tipsStore.systemEnabled; persistRuntimeState(); saveHelpfulMessages(); - const status = tipsStore.systemEnabled ? '🟢 Tips System Enabled' : '🔴 Tips System Disabled'; + const status = tipsStore.systemEnabled ? '🟢 Content Drops System Enabled' : '🔴 Content Drops System Disabled'; await ctx.reply(status); await sendTipsDashboard(ctx); }); @@ -7425,7 +7703,7 @@ bot.action('tips_cmd_toggle', async (ctx) => { bot.action('tips_cmd_list', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - const lines = ['📋 *Current Tips:*\n']; + const lines = ['📋 *Current Content Drops:*\n']; for (const [idx, tip] of tipsStore.tips.entries()) { const preview = tip.text.replace(/\*/g, '').slice(0, 80); lines.push(`${idx + 1}. #${tip.id} ${tip.enabled ? '✅' : '🔇'} ${preview}${tip.text.length > 80 ? '…' : ''}`); @@ -7442,17 +7720,15 @@ bot.action('tips_cmd_test', async (ctx) => { ? enabled.filter((t) => t.id !== tipsStore.lastSentTipId) : enabled; const tip = pool[Math.floor(Math.random() * pool.length)]; - try { - await bot.telegram.sendMessage(tipsStore.targetGroup, formatTipForHtml(tip.text), { - parse_mode: 'HTML', - disable_notification: true, - }); + const sendResult = await postTipToConfiguredTarget(tip, bot.telegram); + if (sendResult.ok) { tipsStore.lastSentTipId = tip.id; persistRuntimeState(); saveHelpfulMessages(); - await ctx.reply(`✅ Test tip posted to ${tipsStore.targetGroup} (silent).`); - } catch (e) { - await ctx.reply(`❌ Failed to post test tip to ${tipsStore.targetGroup}: ${e.message}`); + await ctx.reply(`✅ Test tip posted to ${sendResult.target} (silent).`); + } else { + await ctx.reply(`❌ Failed to post test tip. ${sendResult.error ? sendResult.error.message : 'Unknown error'} +Tip: run /register_chat and forward a message from the target group/channel.`); } await sendTipsDashboard(ctx); }); @@ -7472,7 +7748,7 @@ bot.action('tips_cmd_settings', async (ctx) => { const user = getUser(ctx); clearPendingAction(user); user.pendingAction = { type: 'await_tip_settings_interval' }; - const text = `⚙️ *Tips Settings*\n\nInterval: every ${tipsStore.intervalHours} hours\nSilent mode: Always ON\nTarget group: ${tipsStore.targetGroup}\n\nSend the new interval in hours (e.g. \`4\`).`; + const text = `⚙️ *Content Drops Settings*\n\nInterval: every ${tipsStore.intervalHours} hours\nSilent mode: Always ON\nTarget group: ${tipsStore.targetGroup}\n\nSend the new interval in hours (e.g. \`4\`).`; await ctx.reply(text, { parse_mode: 'Markdown' }); }); @@ -7494,7 +7770,7 @@ bot.action(/^tip_remove_(\d+)$/, async (ctx) => { tipsStore.tips.splice(idx, 1); persistRuntimeState(); saveHelpfulMessages(); - await ctx.reply(`Tip #${tipId} removed.`); + await ctx.reply(`Drop #${tipId} removed.`); }); // tip_edit_select_ @@ -7507,7 +7783,7 @@ bot.action(/^tip_edit_select_(\d+)$/, async (ctx) => { const user = getUser(ctx); clearPendingAction(user); user.pendingAction = { type: 'await_tip_edit_text', data: { tipId } }; - await ctx.reply(`Send the updated text for Tip #${tipId}.\n\nCurrent text:\n${tip.text}`); + await ctx.reply(`Send the updated text for Drop #${tipId}.\n\nCurrent text:\n${tip.text}`); }); // tip_toggle_ (per-tip enable/disable, accessible from tiplist) @@ -7520,7 +7796,7 @@ bot.action(/^tip_toggle_(\d+)$/, async (ctx) => { tip.enabled = !tip.enabled; persistRuntimeState(); saveHelpfulMessages(); - await ctx.reply(`Tip #${tipId} is now ${tip.enabled ? '✅ enabled' : '🔇 disabled'}.`); + await ctx.reply(`Drop #${tipId} is now ${tip.enabled ? '✅ enabled' : '🔇 disabled'}.`); }); // ========================= @@ -9133,6 +9409,29 @@ bot.command('pick_winner', safeAdminHandler('pick_winner', { usage: '/pick_winne })); // ── Feature 8: Group Validation ─────────────────────────────────────────── + +bot.command('register_chat', safeAdminHandler('register_chat', { usage: '/register_chat', example: '/register_chat (then forward a message)' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const user = getUser(ctx); + user.pendingAction = { type: 'await_register_chat_forward' }; + await ctx.reply('Forward any message from the target channel/group here. If the bot is in that chat with proper permissions, it will be registered for broadcasts and tips.'); +})); + +bot.command('verify_bot_setup', safeAdminHandler('verify_bot_setup', { usage: '/verify_bot_setup', example: '/verify_bot_setup' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const me = await bot.telegram.getMe(); + const checks = [ + `Bot: @${me.username} (${me.id})`, + `Can join groups: ${me.can_join_groups ? 'YES' : 'NO'}`, + `Can read all group messages: ${me.can_read_all_group_messages ? 'YES' : 'NO'} (BotFather privacy mode affects this)`, + `Supports inline queries: ${me.supports_inline_queries ? 'YES' : 'NO'}`, + `Announce channel target: ${broadcastConfigStore.targetChannel}`, + `Content Drops/broadcast group target: ${broadcastConfigStore.targetGroup}`, + `Approved broadcast/group chats tracked: ${approvedGroupsStore.size}`, + ]; + await ctx.reply(`🤖 *Bot Setup Verification*\n\n${checks.join('\n')}\n\nIf group visibility is limited, disable privacy mode in @BotFather and ensure admin rights in each target chat.`, { parse_mode: 'Markdown' }); +})); + bot.command('approve_group', safeAdminHandler('approve_group', { usage: '/approve_group ', example: '/approve_group -1001234567890' }, async (ctx) => { if (!requireAdmin(ctx)) return; const chatId = Number((ctx.message.text.split(/\s+/)[1])); @@ -9457,54 +9756,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) => { @@ -9595,7 +9860,7 @@ async function startBot() { } // Cleanup expired /sshv sessions every minute to keep memory usage bounded. -setInterval(() => { +const sshvGcTimer = setInterval(() => { const now = Date.now(); for (const [adminId, session] of sshvSessions.entries()) { if (now - (session.lastActivity || session.createdAt || 0) > SSHV_SESSION_TTL_MS) { @@ -9603,6 +9868,7 @@ setInterval(() => { } } }, 60 * 1000); +sshvGcTimer.unref(); // Fallback for unmatched callback_data: always acknowledge and provide recovery path. bot.action(/.*/, async (ctx) => { diff --git a/test/smoke.test.js b/test/smoke.test.js index 108dfa0..e34e69b 100644 --- a/test/smoke.test.js +++ b/test/smoke.test.js @@ -1,8 +1,65 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +function collectJsFiles(rootDir) { + const files = []; + const skipDirs = new Set(['.git', 'node_modules', 'data', 'logs']); + + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) walk(path.join(dir, entry.name)); + continue; + } + if (entry.isFile() && entry.name.endsWith('.js')) files.push(path.join(dir, entry.name)); + } + } + + walk(rootDir); + return files; +} + +function extractLiteralIds(source, kind) { + const regex = kind === 'callback' + ? /Markup\.button\.callback\([^,]+,\s*(["'`])((?:\\.|(?!\1).)*)\1\)/g + : /bot\.action\(\s*(["'`])((?:\\.|(?!\1).)*)\1/g; + return new Set(Array.from(source.matchAll(regex), (m) => m[2])); +} + +function extractActionRegexPatterns(source) { + const patterns = []; + const matches = source.matchAll(/bot\.action\(\s*\/(.+?)\/([dgimsuvy]*)/g); + for (const m of matches) { + try { + patterns.push(new RegExp(m[1], m[2])); + } catch (_) { + // Skip malformed patterns in this static smoke check. + } + } + return patterns; +} test('index.js syntax is valid', () => { const result = spawnSync(process.execPath, ['--check', 'index.js'], { encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr || result.stdout); }); + +test('menu callback buttons map to handlers (literal or dynamic patterns)', () => { + const jsFiles = collectJsFiles('.'); + const combinedSource = jsFiles + .map((file) => fs.readFileSync(file, 'utf8')) + .join('\n\n'); + + const callbackButtons = extractLiteralIds(combinedSource, 'callback'); + const literalActionHandlers = extractLiteralIds(combinedSource, 'action'); + const dynamicActionPatterns = extractActionRegexPatterns(combinedSource); + + const uncovered = Array.from(callbackButtons) + .filter((id) => !literalActionHandlers.has(id) && !dynamicActionPatterns.some((pattern) => pattern.test(id))) + .sort(); + + assert.deepEqual(uncovered, [], `Uncovered callback handlers: ${uncovered.join(', ')}`); +}); From cb9236f7551b95c1ff090e0fdfa727fba7b1dd25 Mon Sep 17 00:00:00 2001 From: GambleCodez Affiliates Date: Wed, 25 Feb 2026 04:51:31 -0600 Subject: [PATCH 2/3] Improve admin menu flows, promo guidance, and forward-chat registration --- index.js | 138 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 110 insertions(+), 28 deletions(-) diff --git a/index.js b/index.js index d1760a0..3a8a6cd 100644 --- a/index.js +++ b/index.js @@ -1851,10 +1851,10 @@ function adminDashboardKeyboard(page = 1) { ], [ Markup.button.callback('🛟 Support Tools', 'admin_cat_support'), - Markup.button.callback('📈 Stats', 'admin_stats_menu'), + Markup.button.callback('🧪 Tests & Bugs', 'admin_cat_tests'), ], [ - Markup.button.callback('📄 Admin Help', 'help_tab_admin'), + Markup.button.callback('📈 Stats', 'admin_stats_menu'), Markup.button.callback('▶️ Quick Actions', 'admin_dash_page_2'), ], [Markup.button.callback('⬅️ Back to User Menu', 'to_main_menu')], @@ -1888,6 +1888,7 @@ function adminGiveawayToolsKeyboard() { function adminPromoToolsKeyboard() { return Markup.inlineKeyboard([ [Markup.button.callback('🎛 Promo Manager', 'admin_promo_manager')], + [Markup.button.callback('ℹ️ Promo Step Guide', 'admin_pm_help')], [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')], @@ -2159,7 +2160,11 @@ function adminMainMenuKeyboard(user) { ], [ Markup.button.callback('⚙️ System & Health', 'admin_cat_system'), - Markup.button.callback('📄 Admin Help', 'help_tab_admin'), + Markup.button.callback('🧪 Tests & Bugs', 'admin_cat_tests'), + ], + [ + Markup.button.callback('📟 VPS Console (/sshv)', 'sshv_open'), + Markup.button.callback('💡 Content Drops', 'admin_cmd_tips_dashboard'), ], [Markup.button.callback(toggleLabel, 'admin_cmd_mode_toggle')], [Markup.button.callback('↩ Back to User Menu', 'pamenu_back_user')], @@ -3320,32 +3325,18 @@ function buildHelpPages(user) { '/tiptoggle — Enable/disable tips feature', '/tipsettings — Configure content drop settings', '', - '🐞 BUG REPORTS', - '/bugreports — View all open bug reports', - '/exportbugs — Export all reports as plain text', - '/resolvebug — Mark a report resolved', - '', '📊 ANALYTICS', '/funnel — Conversion funnel stats', '/discord_stats — Discord verification stats', '', - '🔧 SYSTEM', - '/testall — Full static self-test (every feature PASS/FAIL)', - '/testgiveaway — Fake giveaway end-to-end test', - '/health — Live health endpoint data', - '/version — Bot version, Node.js version, uptime', - '/deploy — Trigger live deploy from latest git commit', - '/deploy_status — Last deploy result and timing', - '/logs [lines] — Last N lines of bot log', - '/admin_log [lines] — Admin event log (NDJSON)', - '/admin_backup — Snapshot runtime state', + '🧪 BUGS + TESTS + SYSTEM COMMANDS', + 'Moved to Admin Dashboard → Tests & Bugs / System Tools menus for easier use.', '', 'ℹ️ All commands include smart errors and usage hints.', 'ℹ️ Use /on to enable admin view, /off to hide admin UI.', ].join('\n'), buttons: [ - [Markup.button.callback('🛠 Open Admin Dashboard', 'open_admin_dashboard'), Markup.button.callback('🧪 Run TestAll', 'admin_cmd_testall')], - [Markup.button.callback('🐞 Bug Reports', 'pamenu_bug_reports')], + [Markup.button.callback('🛠 Open Admin Dashboard', 'open_admin_dashboard'), Markup.button.callback('🧪 Tests & Bugs', 'admin_cat_tests')], navRow(6, total), [Markup.button.callback('⬅️ Menu', 'to_main_menu')], ], @@ -3608,7 +3599,7 @@ async function handleAnnounceCommand(ctx) { const user = getUser(ctx); clearPendingAction(user); user.pendingAction = { type: 'await_announcement_text' }; - await ctx.reply('Okay! What would you like to announce?\nYou can send normal text or HTML. After sending text, choose targets (DM/channel/group).'); + await ctx.reply('Okay! What would you like to announce?\nYou can send normal text or HTML. After sending text, choose targets (DM/channel/group).', Markup.inlineKeyboard([[Markup.button.callback('⬅️ Return to Admin Menu', 'open_admin_dashboard')], [Markup.button.callback('❌ Cancel', 'admin_cancel')]])); } bot.command('A', handleAnnounceCommand); @@ -4607,7 +4598,21 @@ bot.action('admin_cmd_announce_start', async (ctx) => { bot.action('admin_cmd_tiptest', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); - await ctx.reply('Use /tiptest to send a test tip to the configured group.'); + const enabled = tipsStore.tips.filter((t) => t.enabled); + if (!enabled.length) { await ctx.reply('No enabled content drops to test.'); return; } + const pool = enabled.length > 1 && tipsStore.lastSentTipId != null + ? enabled.filter((t) => t.id !== tipsStore.lastSentTipId) + : enabled; + const tip = pool[Math.floor(Math.random() * pool.length)]; + const sendResult = await postTipToConfiguredTarget(tip, bot.telegram); + if (sendResult.ok) { + tipsStore.lastSentTipId = tip.id; + persistRuntimeState(); + saveHelpfulMessages(); + await ctx.reply(`✅ Test content drop posted to ${sendResult.target} (silent).`, Markup.inlineKeyboard([[Markup.button.callback('💡 Open Content Drops Manager', 'admin_cmd_tips_dashboard')], [Markup.button.callback('⬅️ Admin Dashboard', 'open_admin_dashboard')]])); + } else { + await ctx.reply(`❌ Failed to post test content drop. ${sendResult.error ? sendResult.error.message : 'Unknown error'}\nTip: use /register_chat or forward any message from the target group/channel here to auto-register.`); + } }); bot.action('admin_broadcast', async (ctx) => { @@ -5408,6 +5413,33 @@ bot.action('admin_cat_support', async (ctx) => { await replaceCallbackPanel(ctx, '🛟 *Support Tools*', { parse_mode: 'Markdown', ...adminSupportToolsKeyboard() }); }); + +function adminTestsToolsKeyboard() { + return Markup.inlineKeyboard([ + [Markup.button.callback('🧪 Run TestAll', 'admin_cmd_testall'), Markup.button.callback('🎁 Test Giveaway', 'admin_cmd_testgiveaway')], + [Markup.button.callback('🐛 View Bug Reports', 'admin_cmd_viewbugs'), Markup.button.callback('📤 Export Bugs', 'admin_cmd_exportbugs')], + [Markup.button.callback('✅ Resolve Bug', 'admin_cmd_resolvebug_prompt'), Markup.button.callback('🧪 Test Content Drop', 'admin_cmd_tiptest')], + [Markup.button.callback('📟 Open SSHV Console', 'sshv_open')], + [Markup.button.callback('⬅️ Admin Dashboard', 'open_admin_dashboard')], + ]); +} + +bot.action('admin_cat_tests', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await replaceCallbackPanel(ctx, + `🧪 *Tests & Bug Tools* + +Use this menu for diagnostics, bug workflows, and test sends. + +• TestAll = full sanity check +• Test Giveaway = simulated giveaway flow +• Test Content Drop = sends one random drop to current target +• SSHV = VPS console tools`, + { parse_mode: 'Markdown', ...adminTestsToolsKeyboard() }, + ); +}); + // Inline triggers for admin dashboard quick-action buttons bot.action('admin_cmd_start_giveaway', async (ctx) => { if (!requireAdmin(ctx)) return; @@ -5949,6 +5981,33 @@ bot.action('admin_promo_manager', async (ctx) => { await replaceCallbackPanel(ctx, promoManagerSummaryText(), { parse_mode: 'Markdown', ...adminPromoToolsKeyboard() }); }); + +bot.action('admin_pm_help', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + const guide = [ + 'ℹ️ *Promo Manager Step Guide*', + '', + 'Create Promo is a 9-step wizard. Example values:', + '1) Name: `New User Bonus`', + '2) Code/Link: `WELCOME35` or `https://casino.example/promo`', + '3) Casino base URL: `casino.example`', + '4) Description: `Claim 3.5 SC for first-time users`', + '5) Image URL (optional): `https://.../promo.png` or `skip`', + '6) Requirement type: `A` New user, `B` Existing user, `C` Existing + wager', + ' If C: enter wager SC amount, then rolling days (e.g. `3000`, `7`)', + '7) Claim limit: number or `none`', + '8) Cooldown hours: number or `none`', + '9) Approval mode: `auto` or `admin`', + '', + 'Tips:', + '• Use base domain only in step 3 (no path/query).', + '• Use clear names so users know who the promo is for.', + '• You can preview, pause, and edit anytime from this menu.', + ].join('\n'); + await replaceCallbackPanel(ctx, guide, { parse_mode: 'Markdown', ...adminPromoToolsKeyboard() }); +}); + bot.action('admin_view', async (ctx) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery(); @@ -6430,6 +6489,30 @@ bot.on('inline_query', safeStepHandler('inline_query', async (ctx) => { await ctx.answerInlineQuery(filtered, { cache_time: 5, is_personal: true }); })); + +function extractForwardedChat(msg = {}) { + return msg.forward_from_chat + || (msg.forward_origin && msg.forward_origin.chat) + || (msg.sender_chat ? msg.sender_chat : null) + || null; +} + +async function autoRegisterForwardedChatIfPresent(ctx, user) { + if (!isAdmin(ctx)) return false; + const fwdChat = extractForwardedChat(ctx.message || {}); + if (!fwdChat || !fwdChat.id) return false; + const chatId = Number(fwdChat.id); + approvedGroupsStore.add(chatId); + tipsStore.targetGroup = String(chatId); + broadcastConfigStore.targetGroup = String(chatId); + persistRuntimeState(); + await ctx.reply(`✅ Auto-registered target chat from forwarded message: +• ${fwdChat.title || chatId} (${chatId}) + +Content Drops + group broadcasts now use this target.`); + return true; +} + // ========================= // Text message handler for pending actions // ========================= @@ -6470,6 +6553,8 @@ bot.on('text', async (ctx) => { return; } + if (!user.pendingAction && await autoRegisterForwardedChatIfPresent(ctx, user)) return; + // Smart username detection — fires when there is NO pending action, the message looks // like a Runewager username (3-30 chars, alphanumeric/underscore/hyphen/dot, no spaces), // and the user has not yet linked an account. Always requires confirmation — never auto-accepts. @@ -6526,10 +6611,7 @@ bot.on('text', async (ctx) => { if (action.type === 'await_register_chat_forward') { if (!requireAdmin(ctx)) return; - const msg = ctx.message || {}; - const fwdChat = msg.forward_from_chat - || (msg.forward_origin && msg.forward_origin.chat) - || (msg.sender_chat ? msg.sender_chat : null); + const fwdChat = extractForwardedChat(ctx.message || {}); if (!fwdChat || !fwdChat.id) { await ctx.reply('Please forward a message from the target channel/group.'); return; @@ -6540,10 +6622,10 @@ bot.on('text', async (ctx) => { broadcastConfigStore.targetGroup = String(chatId); user.pendingAction = null; persistRuntimeState(); - await ctx.reply(`✅ Registered chat for broadcasts/tips: + await ctx.reply(`✅ Registered chat for broadcasts/content drops: • ${fwdChat.title || chatId} (${chatId}) -Tips and group broadcasts will now use this target.`); +Content Drops and group broadcasts will now use this target.`); return; } From 96db6928247008b3b19cb751c145076ae512249d Mon Sep 17 00:00:00 2001 From: GambleCodez Affiliates Date: Wed, 25 Feb 2026 04:54:49 -0600 Subject: [PATCH 3/3] Fix smoke callback scan for templated callback ids --- test/smoke.test.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/smoke.test.js b/test/smoke.test.js index e34e69b..6cf88af 100644 --- a/test/smoke.test.js +++ b/test/smoke.test.js @@ -26,7 +26,15 @@ function extractLiteralIds(source, kind) { const regex = kind === 'callback' ? /Markup\.button\.callback\([^,]+,\s*(["'`])((?:\\.|(?!\1).)*)\1\)/g : /bot\.action\(\s*(["'`])((?:\\.|(?!\1).)*)\1/g; - return new Set(Array.from(source.matchAll(regex), (m) => m[2])); + const ids = []; + for (const m of source.matchAll(regex)) { + const quote = m[1]; + const id = m[2]; + // Skip templated callback ids (e.g. `help_page_${page + 1}`), they are dynamic. + if (kind === 'callback' && quote === '`' && id.includes('${')) continue; + ids.push(id); + } + return new Set(ids); } function extractActionRegexPatterns(source) {