diff --git a/CLAUDE.md b/CLAUDE.md index b2bc9d7..8780676 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,7 @@ You are the Runewager Bot Ops Commander. You manage the full lifecycle of the Ru - Use `clearOldMenus(ctx)` (or approved wrapper) for menu-card transitions to enforce single-active-menu behavior. - Keep link behavior deterministic: browser mode => external web URL only; telegram mode => startapp deep link only; Discord URLs must remain external browser links. - `/testall` updates must preserve structured category checks and final summary: `TestAll complete β€” X passed, Y warnings, Z failures.` +- Referral onboarding rules must be enforced in code/tests: one-time code entry during onboarding only, no self-referral, no post-onboarding referral application, and dual 7-day boost assignment on valid referral. ## Workflow Patterns 1. git pull origin main diff --git a/RUNEWAGER_FUNCTIONALITY_MAP.md b/RUNEWAGER_FUNCTIONALITY_MAP.md index 5cc907f..cc1d183 100644 --- a/RUNEWAGER_FUNCTIONALITY_MAP.md +++ b/RUNEWAGER_FUNCTIONALITY_MAP.md @@ -55,6 +55,7 @@ Navigation is driven by inline menus plus command aliases. Persistent user/admin - Includes onboarding/account actions, promo/bonus entry points, community links, giveaways, help, settings. ### Key user branches +- Referral system: Main Menu exposes **πŸ‘₯ Refer a Friend** submenu (`Your Referral Code`, `Share Your Code`, `How Referral Boosts Work`, Back/Cancel). - Play-link routing is deterministic: browser mode always returns external `https://gamble-codez.com/...`; telegram mode always returns `startapp` mini-app deep links. - Discord links are browser-only via `getDiscordLink(...)` sanitation (no WebAppInfo/startapp wrappers). - **Profile & status**: `/profile`, quick status callbacks. @@ -139,6 +140,8 @@ The bot uses `user.pendingAction.type` as its input state machine. Key families: ## 9. Onboarding Flow (full step map) 1. `/start` intro + age confirmation gate. +2. After age confirmation, onboarding asks: **Were you referred by a friend?** (`Yes`/`No`). +3. If yes, user enters a referral code once; valid code applies 2Γ— giveaway boost to both users for 7 days and stores `{ referral_code_owner, referred_user_id, boost_expiration_timestamp }`. 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. @@ -246,6 +249,8 @@ Pending-action timeout handling is enforced in the text input router: each pendi - Markdown escaping utilities for user/path/output interpolation. - Command error wrappers to reduce undefined behavior on invalid usage. +- Share-ready referral HTML template is generated via helper and opened with Telegram native share URL (`t.me/share/url`) while preserving external join link. + ## 20. Edge Cases & Special Conditions - Empty states (no giveaways/promos/bugs) return explicit panels. diff --git a/index.js b/index.js index 4c8a6b8..80a53e7 100644 --- a/index.js +++ b/index.js @@ -377,7 +377,7 @@ const MAX_ANALYTICS_EVENTS = Number(process.env.MAX_ANALYTICS_EVENTS || 2000); const MAX_ONBOARDING_STEPS_HISTORY = Number(process.env.MAX_ONBOARDING_STEPS_HISTORY || 500); const promoHistoryStore = new Map(); const analyticsStore = { events: [], onboardingStarts: 0, onboardingCompletions: 0, webAppEvents: 0 }; -const referralStore = { boosts: [], archivedBoosts: [], links: new Map(), stats: new Map(), weekly: new Map() }; +const referralStore = { boosts: [], archivedBoosts: [], links: new Map(), stats: new Map(), weekly: new Map(), referrals: [] }; const approvedGroupsStore = new Set(); // Feature 8: set of chat_id numbers approved for giveaway posting const broadcastFailedUsers = []; // Feature 5: [{userId, lastError, failedAt}] permanently failed users const smartButtonTracker = new Map(); // token -> expiry timestamp (TTL = 2 minutes) @@ -446,6 +446,7 @@ const ACTION_LABELS = { await_sshv_editor_content: 'SSHV editor content', await_sshv_danger_confirm: 'dangerous SSHV confirmation', await_register_chat_forward: 'chat registration forward', + await_referral_code: 'referral code entry', }; /** @@ -830,6 +831,7 @@ function createRuntimeStateSnapshot() { links: Array.from(referralStore.links.entries()), stats: Array.from(referralStore.stats.entries()), weekly: Array.from(referralStore.weekly.entries()), + referrals: referralStore.referrals || [], }, approvedGroupsStore: Array.from(approvedGroupsStore), broadcastFailedUsers, @@ -1055,6 +1057,7 @@ function loadRuntimeState() { referralStore.links = new Map(raw.referralStore.links || []); referralStore.stats = new Map(raw.referralStore.stats || []); referralStore.weekly = new Map(raw.referralStore.weekly || []); + referralStore.referrals = Array.isArray(raw.referralStore.referrals) ? raw.referralStore.referrals : []; } if (Array.isArray(raw.approvedGroupsStore)) { for (const id of raw.approvedGroupsStore) approvedGroupsStore.add(Number(id)); @@ -1261,6 +1264,8 @@ function createDefaultUser(user) { referralCount: 0, lastReferralAt: 0, boostExpiresAt: 0, // ms timestamp; if > now, winner gets 2Γ— SC + referredByUserId: 0, + referralAppliedAt: 0, commandsUsed: 0, sessionsCount: 0, wagerBonus: { @@ -2538,7 +2543,7 @@ function mainMenuKeyboard(isAdminUser = false, page = 1, user = null) { Markup.button.url('πŸ’¬ Discord', LINKS.rwDiscordJoin), ], [ - Markup.button.callback('πŸ† Referral Link', 'menu_referral'), + Markup.button.callback('πŸ‘₯ Refer a Friend', 'menu_referral'), Markup.button.callback('πŸ‘€ My Profile', 'menu_profile_action'), ], [ @@ -2596,7 +2601,7 @@ function mainMenuKeyboard(isAdminUser = false, page = 1, user = null) { if (user && user.settings && user.settings.showQuickCommands) { rows.push([ Markup.button.callback('β–Ά Giveaways', 'menu_giveaways'), - Markup.button.callback('β–Ά Referral', 'menu_referral'), + Markup.button.callback('πŸ‘₯ Refer a Friend', 'menu_referral'), Markup.button.callback('β–Ά Status', 'menu_qc_status'), Markup.button.callback('β–Ά Help', 'menu_help'), ]); @@ -4602,6 +4607,48 @@ function referralCodeForUser(user) { return referralStore.links.get(user.id); } + +function referralShareHTML(code) { + return `🚨 YO! I’m farming free SC on Runewager and you need to get in NOW. + +Daily giveaways, instant rewards, drops, and a secret new-user bonus waiting inside the bot. + +Use my code ${code} when you start β€” we BOTH get a 2Γ— boost on all giveaways for 7 days. + +πŸ‘‰ Tap here to join Runewager + +Don’t sleep. This thing prints. βš‘πŸ‘‘`; +} + +function applyOnboardingReferralCode(user, referralCode) { + const code = String(referralCode || '').trim().toLowerCase(); + if (!code) return { ok: false, reason: 'Please enter a referral code.' }; + if (!user.ageConfirmed) return { ok: false, reason: 'Referral codes can only be entered during onboarding.' }; + if ((user.referralAppliedAt || 0) > 0 || (user.referredByUserId || 0) > 0) return { ok: false, reason: 'Referral code already used on this account.' }; + if (user.onboarding && user.onboarding.completedAt) return { ok: false, reason: 'Referral code can only be entered before onboarding is complete.' }; + + let owner = null; + for (const [, u] of userStore) { + if (String(referralCodeForUser(u) || '').toLowerCase() === code) { owner = u; break; } + } + if (!owner) return { ok: false, reason: 'Referral code not found.' }; + if (Number(owner.id) === Number(user.id)) return { ok: false, reason: 'You cannot use your own referral code.' }; + + const now = Date.now(); + const expiresAt = now + 7 * 24 * 60 * 60 * 1000; + user.referredByUserId = owner.id; + user.referralTag = code; + user.referralAppliedAt = now; + user.boostExpiresAt = Math.max(Number(user.boostExpiresAt || 0), expiresAt); + owner.boostExpiresAt = Math.max(Number(owner.boostExpiresAt || 0), expiresAt); + owner.referralCount = (owner.referralCount || 0) + 1; + owner.lastReferralAt = now; + referralStore.referrals.push({ referral_code_owner: owner.id, referred_user_id: user.id, boost_expiration_timestamp: expiresAt, created_at: now }); + awardReferralProgress(owner.id, 1); + persistRuntimeState(); + return { ok: true, ownerId: owner.id, expiresAt }; +} + /** * getActiveReferralBoost executes its scoped Runewager logic and participates in menu/command or utility flow composition. @@ -7414,7 +7461,34 @@ bot.action('age_yes', async (ctx) => { trackOnboardingProgress(user); await ctx.answerCbQuery('Age confirmed.'); await reactToMessage(ctx, 'πŸ‘'); - // Show GambleCodez VIP onboarding step before main menu + await sendOnboardingReferralPrompt(ctx, user); +}); + + +async function sendOnboardingReferralPrompt(ctx, user) { + if ((user.referralAppliedAt || 0) > 0 || (user.referredByUserId || 0) > 0) { + await sendGambleCodezVIPStep(ctx, user); + return; + } + await ctx.reply( + 'Were you referred by a friend?', + Markup.inlineKeyboard([ + [Markup.button.callback('βœ… Yes, I was referred', 'onboard_ref_yes')], + [Markup.button.callback('➑️ No, continue', 'onboard_ref_no')], + ]), + ); +} + +bot.action('onboard_ref_yes', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + user.pendingAction = { type: 'await_referral_code', createdAt: Date.now() }; + await ctx.reply('Please enter your referral code now.'); +}); + +bot.action('onboard_ref_no', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); await sendGambleCodezVIPStep(ctx, user); }); @@ -7834,14 +7908,39 @@ bot.command('pmdeny', safeAdminHandler('pmdeny', { usage: '/pmdeny { const user = getUser(ctx); await ctx.answerCbQuery(); - const code = referralCodeForUser(user); - const link = `https://t.me/${ctx.botInfo.username}?start=ref_${code}`; - await ctx.reply(`πŸ† *Your Referral Link*\n\n${link}\n\nShare this link to earn referral rewards!`, { + await ctx.reply('πŸ‘₯ *Refer a Friend*\n\nChoose an option:', { parse_mode: 'Markdown', - ...Markup.inlineKeyboard([[Markup.button.callback('⬅️ Menu', 'to_main_menu')]]), + ...Markup.inlineKeyboard([ + [Markup.button.callback('🧾 Your Referral Code', 'ref_menu_code')], + [Markup.button.callback('πŸ“€ Share Your Code', 'ref_menu_share')], + [Markup.button.callback('ℹ️ How Referral Boosts Work', 'ref_menu_how')], + [Markup.button.callback('⬅️ Back', 'to_main_menu')], + [Markup.button.callback('❌ Cancel', 'to_main_menu')], + ]), }); }); +bot.action('ref_menu_code', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + const code = referralCodeForUser(user); + await ctx.reply(`Your referral code: \`${code}\``, { parse_mode: 'Markdown' }); +}); + +bot.action('ref_menu_how', async (ctx) => { + await ctx.answerCbQuery(); + await ctx.reply('Referral boosts: both you and your referred friend get 2Γ— giveaway boost for 7 days when a valid referral code is entered during onboarding.'); +}); + +bot.action('ref_menu_share', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + const code = referralCodeForUser(user); + const html = referralShareHTML(code); + const shareUrl = `https://t.me/share/url?url=${encodeURIComponent('https://t.me/RunewagerBot')}&text=${encodeURIComponent(html)}`; + await ctx.reply('Tap below to open Telegram share sheet:', Markup.inlineKeyboard([[Markup.button.url('πŸ“€ Share Your Code', shareUrl)], [Markup.button.callback('⬅️ Back', 'menu_referral')]])); +}); + bot.action('menu_bugreport', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); @@ -9623,6 +9722,19 @@ Content Drops and group broadcasts will now use this target.`); } // User linking flow β€” NEVER auto-accept; always require explicit confirmation + + if (action.type === 'await_referral_code') { + const result = applyOnboardingReferralCode(user, text); + if (!result.ok) { + await ctx.reply(`❌ ${result.reason}`, Markup.inlineKeyboard([[Markup.button.callback('❌ Skip Referral', 'onboard_ref_no')]])); + return; + } + user.pendingAction = null; + await ctx.reply('βœ… Referral applied! Both accounts now have a 2Γ— giveaway boost for 7 days.'); + await sendGambleCodezVIPStep(ctx, user); + return; + } + if (action.type === 'await_runewager_username') { if (!text) { await ctx.reply( diff --git a/test/smoke.test.js b/test/smoke.test.js index 69d015d..4e1f65d 100644 --- a/test/smoke.test.js +++ b/test/smoke.test.js @@ -524,3 +524,20 @@ test('testall emits required final summary line', () => { const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); assert.ok(source.includes('TestAll complete β€” ${totalPassed} passed, ${totalWarnings} warnings, ${totalFailed} failures.'), 'Expected TestAll final summary string'); }); + + +test('referral menu includes required friend-invite actions', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + for (const cb of ['menu_referral', 'ref_menu_code', 'ref_menu_share', 'ref_menu_how']) { + assert.ok(source.includes(cb), `Expected referral callback: ${cb}`); + } + assert.ok(source.includes('Were you referred by a friend?'), 'Expected onboarding referral prompt text'); + assert.ok(source.includes('await_referral_code'), 'Expected referral onboarding pending action'); +}); + +test('testall covers required diagnostic category labels', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8'); + for (const cat of ['Environment Checks', 'Database Checks', 'Navigation Checks', 'PendingAction Checks']) { + assert.ok(source.includes(cat), `Expected TestAll category/check mention: ${cat}`); + } +}); diff --git a/test/unit.test.js b/test/unit.test.js index 76b68b0..09d1810 100644 --- a/test/unit.test.js +++ b/test/unit.test.js @@ -472,6 +472,32 @@ test('discord links remain external and unwrap telegram wrappers', () => { assert.ok(!out.includes('t.me')); }); + + +function referralShareHTML(code) { + return `🚨 YO! I’m farming free SC on Runewager and you need to get in NOW. + +Daily giveaways, instant rewards, drops, and a secret new-user bonus waiting inside the bot. + +Use my code ${code} when you start β€” we BOTH get a 2Γ— boost on all giveaways for 7 days. + +πŸ‘‰ Tap here to join Runewager + +Don’t sleep. This thing prints. βš‘πŸ‘‘`; +} + +function applyOnboardingReferralCodeLike(user, owner, codeInput, ownerCode = 'abc123') { + const code = String(codeInput || '').trim().toLowerCase(); + if (!code) return { ok: false, reason: 'Please enter a referral code.' }; + if (!user.ageConfirmed) return { ok: false, reason: 'Referral codes can only be entered during onboarding.' }; + if ((user.referralAppliedAt || 0) > 0 || (user.referredByUserId || 0) > 0) return { ok: false, reason: 'Referral code already used on this account.' }; + if (user.onboarding && user.onboarding.completedAt) return { ok: false, reason: 'Referral code can only be entered before onboarding is complete.' }; + if (String(ownerCode).toLowerCase() !== code) return { ok: false, reason: 'Referral code not found.' }; + if (owner.id === user.id) return { ok: false, reason: 'You cannot use your own referral code.' }; + const expiresAt = 1700000000000 + 7 * 24 * 60 * 60 * 1000; + return { ok: true, ownerId: owner.id, expiresAt }; +} + // --------------------------------------------------------------------------- // Promo claim limit check (pure logic) // --------------------------------------------------------------------------- @@ -491,3 +517,36 @@ test('remaining claims decrements correctly', () => { remainingClaims = 0; assert.ok(remainingClaims <= 0); // no more claims }); + + +test('referral share html includes code', () => { + const html = referralShareHTML('rwabc'); + assert.ok(html.includes('rwabc')); + assert.ok(html.includes('2Γ— boost')); + assert.ok(html.includes('https://t.me/RunewagerBot')); +}); + +test('referral abuse prevention blocks self-referral', () => { + const user = { id: 1, ageConfirmed: true, referralAppliedAt: 0, referredByUserId: 0, onboarding: { completedAt: 0 } }; + const owner = { id: 1 }; + const out = applyOnboardingReferralCodeLike(user, owner, 'abc123'); + assert.equal(out.ok, false); + assert.match(out.reason, /cannot use your own/i); +}); + +test('referral code accepted only during onboarding', () => { + const user = { id: 2, ageConfirmed: true, referralAppliedAt: 0, referredByUserId: 0, onboarding: { completedAt: 1700 } }; + const owner = { id: 1 }; + const out = applyOnboardingReferralCodeLike(user, owner, 'abc123'); + assert.equal(out.ok, false); + assert.match(out.reason, /before onboarding is complete/i); +}); + +test('valid referral applies boost expiry shape', () => { + const user = { id: 2, ageConfirmed: true, referralAppliedAt: 0, referredByUserId: 0, onboarding: { completedAt: 0 } }; + const owner = { id: 1 }; + const out = applyOnboardingReferralCodeLike(user, owner, 'abc123'); + assert.equal(out.ok, true); + assert.equal(out.ownerId, 1); + assert.ok(out.expiresAt > 1700000000000); +});