From a474ddc8edf2e5a6d17a0263312634d8ff2f8d00 Mon Sep 17 00:00:00 2001 From: GambleCodez Affiliates Date: Thu, 26 Feb 2026 14:20:28 -0600 Subject: [PATCH] Audit complete: Fixed missing functionality + Updated Functionality Map --- .env.example | 2 +- .gitignore | 7 + CLAUDE.md | 8 + RUNEWAGER_FUNCTIONALITY_MAP.md | 321 ++++++ index.js | 1836 +++++++++++++++++++------------- test/smoke.test.js | 65 ++ 6 files changed, 1494 insertions(+), 745 deletions(-) create mode 100644 RUNEWAGER_FUNCTIONALITY_MAP.md 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/CLAUDE.md b/CLAUDE.md index c607fad..fb904a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,3 +140,11 @@ You are the Runewager Bot Ops Commander. You manage the full lifecycle of the Ru - Bonus state machine with transition guards is solid - Structured JSON logging throughout - Graceful SIGTERM/SIGINT with state persist before exit + +--- + +## AI Coder Contract Requirements (Mandatory) + +Before writing or modifying any code, the AI must always read and familiarize itself with `RUNEWAGER_FUNCTIONALITY_MAP.md`. This file is the authoritative source of truth for all bot functionality. The AI must keep this file updated with full, easy-to-understand descriptions whenever functionality is added, changed, or removed. Updating this file is a required step at the end of every coding session. + +After generating or modifying any functionality, the AI must run a follow-up audit to ensure the `RUNEWAGER_FUNCTIONALITY_MAP.md` file is fully updated and accurate. No coding session is complete until the map is updated and verified. diff --git a/RUNEWAGER_FUNCTIONALITY_MAP.md b/RUNEWAGER_FUNCTIONALITY_MAP.md new file mode 100644 index 0000000..416935c --- /dev/null +++ b/RUNEWAGER_FUNCTIONALITY_MAP.md @@ -0,0 +1,321 @@ +# RUNEWAGER_FUNCTIONALITY_MAP.md + +_Last audited: 2026-02-26_ +_Source of truth files: `index.js`, `test/*.test.js`, scripts under `scripts/`, deployment/runtime docs in repo root._ + +--- + +## 1. High‑Level Overview + +Runewager is a Telegraf-based Telegram bot that provides: +- User onboarding (age gate, account/Discord guidance, username linking). +- Promo flows (DB-backed promo manager + eligibility + claim lifecycle). +- Giveaway flows (creation, join, eligibility checks, auto finalization, admin controls). +- Content Drops (scheduled/random posts to configured target chat). +- Admin operations (broadcasts, diagnostics, SSHV console, bug triage, backups). +- Runtime health and deploy tooling (`/health`, scripts, systemd template). + +Navigation is driven by inline menus plus command aliases. Persistent user/admin menu headers are used in DMs for fast access. + +## 2. System Architecture Summary + +### Core runtime +- Single runtime app in `index.js`. +- Telegraf command handlers + callback handlers. +- Per-user mutable state stored in memory and persisted to JSON runtime snapshots. + +### State/storage layers +- In-memory stores: users, giveaway state, analytics, promo manager store, content drops store, broadcast config, SSHV sessions. +- File persistence under `data/` (runtime snapshots + promo DB + optional backups). +- Periodic persistence timer + startup restore. + +### Routing model +- `bot.command(...)` for slash commands. +- `bot.action(...)` for inline button callbacks (literal + regex handlers). +- `bot.on('text')` for pending “await input” state machine. + +### Utility subsystems +- Eligibility checks, validation helpers, markdown escaping, analytics/event logging. +- Admin audit logging (`adminLog` and NDJSON appends). +- SSHV command execution safety checks and state restoration. + +## 3. Global Bot Behaviors + +- Unknown command handling routes to a structured error block with guidance. +- `requireAdmin(ctx)` enforces admin-only access without implicitly toggling admin mode. +- `sendCommandError(...)` standardizes command failure messaging. +- Menus use callback “back” paths (`to_main_menu`, `open_admin_dashboard`, `pamenu_back_*`). +- Many menu-style responses use `replaceCallbackPanel(...)` to avoid stale stacked cards. + +## 4. User Menu Tree + +### Main User Menu +- Core entry via `/start`, `/menu`, or persistent header. +- Includes onboarding/account actions, promo/bonus entry points, community links, giveaways, help, settings. + +### Key user branches +- **Profile & status**: `/profile`, quick status callbacks. +- **Promo**: `menu_claim_bonus` → filtered eligible promos only. +- **Giveaways**: `menu_giveaways` and `/mygiveaways` for active and personalized entries. +- **Walkthrough/help**: paginated booklet (`help_page_N`) and walkthrough steps (`walk_next/back/done`). +- **Settings**: play mode + quick commands + tooltip toggles. + +### Expected input & fallback +- Non-command text when no pending state is mostly ignored except smart username detect. +- Invalid/unknown commands get standard help guidance. + +## 5. Admin Menu Tree + +### Persistent Admin Main Menu +- User Ops & Stats +- Giveaway Ops +- Promo Manager +- Broadcasts & Drops +- System & Health +- Tests & Bugs +- VPS Console (/sshv) +- Content Drops manager shortcut + +### Admin category menus +- `admin_cat_giveaway`: start/test/status. +- `admin_cat_promo`: full promo manager actions + guide. +- `admin_cat_system`: health/version/verify/setup/backup/admin mode/testall/sshv. +- `admin_cat_tests`: bug tools + test tools + sshv shortcut. +- `admin_cat_support`: bug report management shortcuts. + +### Restrictions +- All above require `requireAdmin(ctx)`. +- Many actions also require pending state sequencing and validation checks. + +## 6. Button‑to‑Action Map + +### Dynamic callback families (regex handlers) +- `help_page_` +- `page_giveaways_` +- `promo_open_`, `promo_claim_` +- `gw_join_`, `gw_details_`, `gw_elig_` and giveaway admin variants +- `pamenu_gw_end_`, `pamenu_gw_extend_`, `pamenu_gw_cancel_`, `pamenu_gw_participants_` +- `tip_edit_select_`, `tip_remove_`, `tip_toggle_` + +### Representative literal callbacks +- Menu routing: `to_main_menu`, `open_admin_dashboard`, `pamenu_back_admin`, `pamenu_back_user` +- Announce builder: `announce_toggle_dm/channel/group/mode`, `announce_send_now`, `announce_edit` +- Promo manager: `admin_pm_create/edit/pause_toggle/delete/stats/preview/queue/help` +- Content drops: `tips_cmd_add/edit/remove/toggle/list/test/settings/import_batch` +- SSHV: `sshv_open`, `sshv_run_prompt`, `sshv_ctrl_c`, `sshv_ctrl_z`, `sshv_editor_save`, `sshv_editor_cancel` + +## 7. Wait‑For‑Input States (with timeouts) + +The bot uses `user.pendingAction.type` as its input state machine. Key families: + +- **Onboarding/account**: `await_runewager_username`, `await_username_confirm`. +- **Announcements**: `await_announcement_text`, `await_announcement_action`. +- **Promo manager wizard**: + - `admin_pm_create_name` → `..._code` → `..._domain` → `..._description` → `..._image` → `..._requirement` → optional wager substeps → claim/cooldown/approval. +- **Giveaway creation/edit**: `gwiz_*`, legacy `gw_*` chains, edit/extend states. +- **Content drops**: `await_tip_add_text`, `await_tip_edit_text`, `await_tip_settings_interval`. +- **Admin utility prompts**: whois/refresh/resolve bug/bonus prompts. +- **SSHV**: `await_sshv_command`, `await_sshv_editor_content`, `await_sshv_danger_confirm`. +- **Forward registration**: `await_register_chat_forward`. + +### Timeouts +- No global pendingAction auto-timeout was found for most flows. +- SSHV sessions have TTL-based GC (`SSHV_SESSION_TTL_MS`), with stale-state normalization and recovery. + +## 8. Fallback Logic & Error Handling + +- Permission failures: `requireAdmin` + `sendCommandError`. +- Invalid pending input: explicit prompts to retry (numeric checks, yes/no checks, IDs). +- Missing entities (promo/giveaway not found): user-visible explanatory replies. +- Announce/test flows return actionable follow-up messages. +- SSHV errors include sandbox hints for EROFS/EACCES/privilege constraints. + +## 9. Onboarding Flow (full step map) + +1. `/start` intro + age confirmation gate. +2. Account setup guidance (Runewager + Discord verification steps). +3. Username linking via `/link` or text detect + confirm callbacks. +4. Promo and bonus prompts depending on linked state and eligibility. +5. Community join prompts (channel/group flags). +6. Walkthrough progression tracking (`user.walkthrough`, onboarding milestones). + +Recovery: +- `confirm_no_username` returns to username entry. +- `/stuck` and `/fixaccount` provide guided recovery paths. + +## 10. Group Commands + +Group-aware commands include giveaway interaction and linking shortcuts: +- `/giveaway` (admin wizard in group context). +- `/join` and `gw_join_` for participant entry. +- `/eligible [id]` checks eligibility. +- `/link ` from group attempts DM handoff for confirmation. + +Restrictions: +- Admin-only commands require admin identity even in groups. +- Join surface checks enforce giveaway surface constraints (group vs DM). + +## 11. User Help Booklet + +- Multi-page booklet with pagination callbacks. +- Covers: overview, account setup, promo/bonus usage, giveaways/community, support. +- Back/menu buttons included on each page. + +## 12. Admin Help Booklet + +- Admin page includes admin command references and points operators to dashboard categories. +- Bugs/tests/system-heavy workflows are now surfaced in dashboard menus (Tests & Bugs / System Tools) to reduce command overload. + +## 13. Full Command Index (User + Admin) + +### User-facing commands (also includes aliases) +`affiliate, bonus, bugreport, cancel, checkin, claim_history, commands, discord, discord_confirm, eligible, fixaccount, giveaway, gwhistory, health, help, join, leaderboard, leaderboard_weekly, link, linkaccount, linkrunewager, menu, mygiveaways, play, profile, promo, promocheck, referral, settings, signup, startapp, status, stuck, support, top, walkthrough` + +### Admin commands +`A, a, announce, admin, admin_backup, admin_log, admin_notify, approve_group, bonusstatus, boost_referrals, boostmeter, broadcast_failed, broadcast_retry, deploy, deploy_status, exportbugs, gw_graphic, gw_pause, gw_resume, list_groups, logs, off, on, pick_winner, pmapprove, pmdeny, promo_cooldown, refreshuser, register_chat, resolvebug, scan_eligibility, setpromo, sshv, start_giveaway, testall, testgiveaway, tipadd, tipedit, tiplist, tipremove, tips, tipsettings, tiptest, tiptoggle, t, tp, unapprove_group, verify_bot_setup, version, wager30_admin, whois` + +Notes: +- Several commands are dual-purpose aliases. +- Many admin buttons call the same underlying command logic. + +## 14. Permissions & Access Control + +- Admin identity is based on `ADMIN_IDS` env list. +- `requireAdmin(ctx)` gates command/callback execution. +- Admin mode UI toggle changes visibility of admin UI, not true authorization. +- Group approvals list controls where certain broadcast/group operations target. + +## 15. State Machine & Session Logic + +Primary per-user state fields: +- `pendingAction` (typed-input router) +- onboarding/walkthrough progress +- promo/giveaway participation flags +- settings (play mode, quick commands, tooltips) +- admin mode display preference + +SSHV session state: +- cwd, output buffer, lock, running process refs, editor mode draft, last activity timestamps. + +## 16. Timeout & Auto‑Cancel Rules + +Configured/observed time behaviors: +- Smart button dedupe TTL: ~2 minutes. +- SSHV session TTL GC: 10 minutes (`SSHV_SESSION_TTL_MS`). +- Periodic persistence intervals and scheduler timers (content drops, maintenance timers). +- Giveaway timers drive auto finalization and countdown behavior. + +No universal pendingAction timeout/auto-cancel was detected for all user/admin input flows. + +## 17. Validation Rules (username, discord, etc.) + +- Username normalization + format checks for Runewager username linking. +- Numeric validations for wager/giveaway/promo fields. +- Yes/no parsing for legacy wizard decisions. +- Promo creation expects base-domain style input for casino field. +- Forward registration validates forwarded chat presence and chat id. + +## 18. Rate Limits & Cooldowns + +- Promo-level cooldown and claim-limit checks in eligibility evaluation. +- Bonus request lifecycle enforces transition constraints and attempt limits. +- Smart button anti-duplicate TTL reduces repeated callback abuse. +- Broadcast failure tracking caps and log caps exist to bound memory growth. + +## 19. Safety & Abuse Prevention Logic + +- Admin gating via `requireAdmin` on sensitive flows. +- SSHV command blocking/confirmation for dangerous shell patterns. +- SSHV sandbox error hinting to clarify denied operations. +- Markdown escaping utilities for user/path/output interpolation. +- Command error wrappers to reduce undefined behavior on invalid usage. + +## 20. Edge Cases & Special Conditions + +- Empty states (no giveaways/promos/bugs) return explicit panels. +- Missing/expired SSHV editor sessions recover to command mode. +- Missing pending context for confirm callbacks returns “Nothing to confirm.” +- Group DM-handoff fallback for `/link` when private DM unavailable. +- Auto-registration accepts forwarded-origin/sender chat variants when available. + +## 21. External Integrations (if any) + +- Telegram Bot API via Telegraf. +- Optional Runewager-related URLs for mini app/profile/promo entry. +- Optional health HTTP/HTTPS server (TLS cert paths env-driven). +- OS/system interactions for logs/deploy/health and SSHV command execution. + +## 22. Internal Utilities & Shared Modules + +Repo modules: +- `index.js` main app. +- `promo-message.js` promo copy helper. +- `scripts/*.sh` deployment/runtime ops (backup, restore, smoke, rollback, diagnostics). +- `test/*.test.js` smoke/unit/runtime tests. + +Key shared helper families in `index.js`: +- Markdown escape helpers. +- command error helper. +- persistence load/save utilities. +- eligibility evaluators (promo/giveaway). +- menu/panel replacement helpers. + +## 23. Feature‑Level Flowcharts + +### Promo claim flow +``` +/promo or menu_claim_bonus + -> load active promos + -> evaluate eligibility per promo + -> eligible list shown + -> user taps promo_open_ + -> details + eligibility result + -> promo_claim_ + -> auto_approve? yes => claimed + -> no => pending_approval queue +``` + +### Announcement flow +``` +admin_cmd_announce_start or /announce + -> await_announcement_text + -> preview + toggles (DM/Channel/Group + parse mode) + -> announce_send_now + -> sendAnnouncementTargets + -> summary result +``` + +### Content drop target registration +``` +Admin forwards message from channel/group + -> autoRegisterForwardedChatIfPresent + -> approvedGroupsStore add(chatId) + -> tipsStore.targetGroup = chatId + -> broadcastConfigStore.targetGroup = chatId +``` + +### Giveaway start/join +``` +Admin starts wizard (gwiz) + -> collect config steps + -> createGiveaway + announceGiveaway + -> users click gw_join_ + -> evaluateEligibility + -> join accepted/rejected + -> timer expires -> finalizeGiveaway +``` + +## 24. Future Updates Log + +- 2026-02-26: Created initial comprehensive functionality map and synchronized with current menu/state architecture. +- 2026-02-26: Added explicit note that template-literal callback IDs are excluded from literal smoke checks. +- 2026-02-26: Documented Tests & Bugs admin category, promo step guide, and forwarded-chat auto-registration behavior. + +## 25. AI Coder Contract Requirements + +Mandatory rules for any AI agent touching this repo: + +1. **Before writing or modifying any code, the AI must always read and familiarize itself with `RUNEWAGER_FUNCTIONALITY_MAP.md`. This file is the authoritative source of truth for all bot functionality. The AI must keep this file updated with full, easy‑to‑understand descriptions whenever functionality is added, changed, or removed. Updating this file is a required step at the end of every coding session.** + +2. **After generating or modifying any functionality, the AI must run a follow-up audit to ensure the `RUNEWAGER_FUNCTIONALITY_MAP.md` file is fully updated and accurate. No coding session is complete until the map is updated and verified.** + diff --git a/index.js b/index.js index 3215ada..3a8a6cd 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'), @@ -1749,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')], @@ -1785,16 +1887,17 @@ 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('ℹ️ 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')], + [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 +1920,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 +2075,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 +2100,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 +2150,22 @@ 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('🧪 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')], ]); @@ -2558,12 +2676,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 +2730,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 +2760,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,41 +3316,27 @@ 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', - '', - '🐞 BUG REPORTS', - '/bugreports — View all open bug reports', - '/exportbugs — Export all reports as plain text', - '/resolvebug — Mark a report resolved', + '/tipsettings — Configure content drop settings', '', '📊 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')], ], @@ -3430,12 +3536,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).', Markup.inlineKeyboard([[Markup.button.callback('⬅️ Return to Admin Menu', 'open_admin_dashboard')], [Markup.button.callback('❌ Cancel', 'admin_cancel')]])); } bot.command('A', handleAnnounceCommand); @@ -3782,8 +3946,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 +4299,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 +4452,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 +4471,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 +4497,138 @@ 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(); + 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) => { + 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 +4716,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 +4777,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 +4815,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 +5194,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.'; - - await ctx.reply( - `🎁 ${audienceLabel} -Promo Code: ${activeCode.code} -Amount: ${activeCode.amountSC} SC each - -${AFFILIATE_REMINDER_TEXT} - -Requirement: ${requirementLabel} - -Steps: -1) Stay registered under GambleCodez affiliate -2) Open the affiliate/promo page -3) Enter the promo code shown above`, - { - parse_mode: 'Markdown', - ...Markup.inlineKeyboard([ - [signupButton()], - [promoButton()], - [Markup.button.callback('ℹ️ Tell me more about the 30 SC bonus', 'w30_bonus_info')], - [Markup.button.callback('✅ I Entered Promo Code', 'promo_confirm_claimed')], - ]), - }, - ); - await sendPhoto(ctx, IMG_PROMO_ENTRY, '📸 Step 3: Enter the promo code here (Menu → Refer a Friend → Promo entry box)'); + 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) }); }); - -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) => { +bot.action('promo_user_claimed_successfully', 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; - } - + user.hasClaimedNewUserPromo = true; + user.lastAnyPromoClaimAt = Date.now(); 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); + await ctx.reply('✅ Got it — you are marked as already claimed. New-user-only promos are now hidden for your account.'); }); -bot.action('promo_confirm_claimed_next', async (ctx) => { +bot.action(/promo_open_(\d+)/, async (ctx) => { const user = getUser(ctx); - if (!consumeSmartButton(user.id, 'promo_confirm_claimed_next')) { - await ctx.answerCbQuery('Already processed'); + 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)} - const result = applyPromoClaimIntent(user); - if (!result.ok) { - await ctx.answerCbQuery(); - await ctx.reply(result.message); - return; +${reasonText}`, { parse_mode: 'Markdown', ...Markup.inlineKeyboard(buttons) }); + if (promo.image_url && isValidHttpUrl(promo.image_url)) { + await sendPhoto(ctx, promo.image_url, `Preview image for ${promo.name}`); } - - await 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(); +bot.action(/promo_claim_(\d+)/, async (ctx) => { 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) => { - 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(); @@ -5369,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; @@ -5381,9 +5452,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 +5498,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 +5638,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 +5656,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 +5735,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 +5759,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 +5966,130 @@ 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()); -}); +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_manage_promo_codes', async (ctx) => { +bot.action('admin_promo_manager', 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')], - ]), - }); + await replaceCallbackPanel(ctx, promoManagerSummaryText(), { parse_mode: 'Markdown', ...adminPromoToolsKeyboard() }); }); -bot.action('admin_promo_code_add', async (ctx) => { + +bot.action('admin_pm_help', 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`); + 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_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')]])); + 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_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.'); -}); - -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 // ========================= @@ -6373,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 // ========================= @@ -6413,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. @@ -6456,9 +6598,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 +6609,26 @@ bot.on('text', async (ctx) => { return; } + if (action.type === 'await_register_chat_forward') { + if (!requireAdmin(ctx)) return; + const fwdChat = extractForwardedChat(ctx.message || {}); + 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/content drops: +• ${fwdChat.title || chatId} (${chatId}) + +Content Drops 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 +6764,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 +7266,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 +7275,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 +7283,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 +7297,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 +7445,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 +7576,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 +7614,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 +7636,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 +7713,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 +7725,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 +7777,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 +7785,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 +7802,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 +7830,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 +7852,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 +7865,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 +7878,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 +9491,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 +9838,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 +9942,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 +9950,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..6cf88af 100644 --- a/test/smoke.test.js +++ b/test/smoke.test.js @@ -1,8 +1,73 @@ 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; + 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) { + 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(', ')}`); +});