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);
+});