diff --git a/.env.example b/.env.example
index ad1845b..62a187e 100644
--- a/.env.example
+++ b/.env.example
@@ -1,8 +1,7 @@
ADMIN_IDS=YOUR_TELEGRAM_USER_ID
AFFILIATE_SOURCE=GambleCodez
ANNOUNCE_CHANNEL="-1002648883359"
-BOTPRIVACYMODE="disabled"
-# BOT_PRIVACY_MODE="disabled" in BotFather/non-privacy mode so bot can read group messages.
+# Set in BotFather/non-privacy mode so bot can read group messages.
BOT_PRIVACY_MODE="disabled"
BOT_TOKEN=
DEVICE=vps
diff --git a/CLAUDE.md b/CLAUDE.md
index 8780676..f0826da 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -171,3 +171,6 @@ Every coding session must begin by reading RUNEWAGER_FUNCTIONALITY_MAP.md and mu
Every coding session must begin by reading RUNEWAGER_FUNCTIONALITY_MAP.md and must end with a full docstring verification pass. No work is complete until every function has a complete docstring and the map is fully updated.
All AI agent instruction files in this repo must enforce this baseline workflow: (1) read and understand `RUNEWAGER_FUNCTIONALITY_MAP.md` before changing code, (2) update the map after any code change, (3) run a full verification pass after the map update, and (4) do not consider work complete until docstrings, the map, and agent instruction files are synchronized.
+
+- Added operational script `load_tooltips.sh` (root) to populate `/var/www/html/Runewager/data/tooltips.json` with 15 approved HTML tooltips; bot now loads this system file on restart when present.
+- Added 30 SC manual-review menu hardening with explicit user/admin submenus and admin audit logging to `/var/www/html/Runewager/logs/bonus_admin.log`.
diff --git a/RUNEWAGER_FUNCTIONALITY_MAP.md b/RUNEWAGER_FUNCTIONALITY_MAP.md
index e1e05f3..44542bf 100644
--- a/RUNEWAGER_FUNCTIONALITY_MAP.md
+++ b/RUNEWAGER_FUNCTIONALITY_MAP.md
@@ -194,7 +194,7 @@ Notes:
## 14. Permissions & Access Control
- Admin identity is based on `ADMIN_IDS` env list.
-- `.env.example` documents both `BOT_PRIVACY_MODE="disabled"` (BotFather non-privacy mode) and compatibility `BOTPRIVACYMODE`, plus Telegram/HTTPS/discovery keys used by runtime checks.
+- `.env.example` documents both `BOT_PRIVACY_MODE="disabled"` (BotFather non-privacy mode), plus Telegram/HTTPS/discovery keys used by runtime checks.
- `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.
@@ -353,3 +353,6 @@ 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.**
+
+- 2026-02-26: Added `load_tooltips.sh` to seed `/var/www/html/Runewager/data/tooltips.json` (15 UTF-8 HTML tooltips) and wired runtime tooltip loading to prefer that system JSON on restart.
+- 2026-02-26: Added deterministic 30 SC user submenu (`How It Works`, `Check My Eligibility`, `Request My Bonus`, `Check Bonus Status`) and Admin submenu (`View Pending Requests`, `Approve Bonus`, `Deny Bonus`, `View User History`, `Reset Attempts`) with manual-review copy and admin action logging to `/var/www/html/Runewager/logs/bonus_admin.log`.
diff --git a/index.js b/index.js
index 41cd0b2..031b442 100644
--- a/index.js
+++ b/index.js
@@ -50,12 +50,17 @@ function getDiscordLink(url) {
return 'https://discord.gg/runewagers';
}
+function getPlayMode(user = null) {
+ const mode = user && user.settings ? user.settings.playMode : user && user.playMode;
+ return mode === 'browser' ? 'browser' : 'miniapp';
+}
+
function getBrowserLink(route = 'play') {
const map = {
- play: 'https://gamble-codez.com/runewager',
- profile: 'https://gamble-codez.com/runewager/profile',
- claim: 'https://gamble-codez.com/runewager/claim',
- affiliate: 'https://gamble-codez.com/runewager/affiliate',
+ play: 'https://runewager.com/r/GambleCodez',
+ profile: 'https://www.runewager.com/profile',
+ claim: 'https://www.runewager.com/affiliate',
+ affiliate: 'https://www.runewager.com/affiliate',
discord: LINKS ? LINKS.rwDiscordJoin : 'https://discord.gg/runewagers',
};
return map[route] || map.play;
@@ -71,8 +76,26 @@ function getStartAppLink(route = 'play') {
}
function getPlayLink(user, route = 'play') {
- const mode = user && user.settings ? user.settings.playMode : 'miniapp';
- return mode === 'browser' ? getBrowserLink(route) : getStartAppLink(route);
+ return getPlayMode(user) === 'browser' ? getBrowserLink(route) : getStartAppLink(route);
+}
+
+function getPlayButton(user = null) {
+ if (getPlayMode(user) === 'browser') {
+ return {
+ text: '๐ต Play & Win',
+ url: 'https://runewager.com/r/GambleCodez',
+ };
+ }
+ return {
+ text: '๐ต Play & Win',
+ web_app: { url: 'https://t.me/RuneWager_bot/Play' },
+ };
+}
+
+function playButtonMarkup(user = null) {
+ const button = getPlayButton(user);
+ if (button.url) return Markup.button.url(button.text, button.url);
+ return Markup.button.webApp(button.text, button.web_app.url);
}
const LINKS = {
@@ -105,7 +128,7 @@ const PROMO_REQUIREMENT = {
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;
-const BOT_PRIVACY_MODE = String(process.env.BOT_PRIVACY_MODE || process.env.BOTPRIVACYMODE || '').trim().toLowerCase();
+const BOT_PRIVACY_MODE = String(process.env.BOT_PRIVACY_MODE || '').trim().toLowerCase();
// Group chat_id for automatic Content Drops
const TIPS_GROUP = process.env.TIPS_GROUP || TELEGRAM_GROUP_ID || '';
@@ -192,6 +215,29 @@ const COPY = {
const bot = new Telegraf(BOT_TOKEN);
+
+// Callback-query hygiene middleware: if a callback handler sends a new reply,
+// remove the source callback card first so menus do not stack.
+bot.use(async (ctx, next) => {
+ if (!ctx.callbackQuery) return next();
+ let cleared = false;
+ const clearSource = async () => {
+ if (cleared) return;
+ try { await ctx.deleteMessage(); } catch (_) { /* stale/missing is fine */ }
+ cleared = true;
+ };
+
+ const wrapReply = (fn) => async (...args) => {
+ await clearSource();
+ return fn(...args);
+ };
+
+ ctx.reply = wrapReply(ctx.reply.bind(ctx));
+ if (ctx.replyWithPhoto) ctx.replyWithPhoto = wrapReply(ctx.replyWithPhoto.bind(ctx));
+ if (ctx.replyWithAnimation) ctx.replyWithAnimation = wrapReply(ctx.replyWithAnimation.bind(ctx));
+ return next();
+});
+
// =========================
// Global Telegraf error handler
// Catches any unhandled error thrown inside a bot.command() or bot.action() handler.
@@ -276,6 +322,8 @@ 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 systemTooltipsFile = '/var/www/html/Runewager/data/tooltips.json';
+const bonusAdminLogFile = '/var/www/html/Runewager/logs/bonus_admin.log';
const sshvSessionsFile = path.join(dataDir, 'sshv-sessions.json');
@@ -364,7 +412,7 @@ function saveHelpfulMessages() {
*/
function loadHelpfulMessages() {
- const raw = loadJson(helpfulMessagesFile, null);
+ const raw = loadJson(systemTooltipsFile, loadJson(helpfulMessagesFile, null));
if (!Array.isArray(raw)) return;
const normalized = raw
.map((entry, idx) => normalizeHelpfulMessageEntry(entry, idx + 1))
@@ -373,6 +421,15 @@ function loadHelpfulMessages() {
tipsStore.tips = normalized;
tipsStore.nextTipId = normalized.reduce((maxId, t) => Math.max(maxId, t.id), 0) + 1;
}
+
+function appendBonusAdminLog(actorId, action, details = '') {
+ try {
+ fs.mkdirSync(path.dirname(bonusAdminLogFile), { recursive: true });
+ const line = `[${new Date().toISOString()}] actor=${actorId || 'system'} action=${action}${details ? ` details=${details}` : ''}
+`;
+ fs.appendFileSync(bonusAdminLogFile, line, 'utf8');
+ } catch (_) { /* ignore log write errors */ }
+}
const backupDir = path.join(dataDir, 'backups');
const MAX_ANALYTICS_EVENTS = Number(process.env.MAX_ANALYTICS_EVENTS || 2000);
const MAX_ONBOARDING_STEPS_HISTORY = Number(process.env.MAX_ONBOARDING_STEPS_HISTORY || 500);
@@ -2356,14 +2413,7 @@ function checkBonusEligibility(user) {
if (user.wagerBonus.attempts >= 3) {
return { ok: false, reason: 'You have reached the maximum of 3 requests.' };
}
- // 4. Wager check โ ask for declaration only when missing
- const declaredWager = Number(user.wagerBonus.wager7dayTotal);
- if (!Number.isFinite(declaredWager) || declaredWager <= 0) {
- return { ok: false, needsWager: true, reason: 'Please declare your 7-day wager total first.' };
- }
- if (declaredWager < 3000) {
- return { ok: false, reason: `Your declared 7-day wager (${declaredWager} SC) is below the required 3,000 SC.` };
- }
+ // 4. Manual-only review policy (no API checks, no auto wager checks)
return { ok: true };
}
@@ -2382,25 +2432,12 @@ async function submitBonusRequest(ctx, user) {
const wb = user.wagerBonus;
const attemptsLeft = 3 - wb.attempts;
await ctx.reply(
- `โ
Your request has been submitted!\n\n`
- + `${AFFILIATE_REMINDER_TEXT}\n\n`
- + `โข You must wager 3,000 SC in any rolling 7-day period\n`
- + `โข Bonus is once per account\n`
- + `โข Attempt ${wb.attempts}/3 used (${attemptsLeft} remaining)\n\n`
- + `Admin has been notified and will manually review and credit your 30 SC bonus on Runewager.`,
- { parse_mode: 'Markdown' },
+ 'Your request has been submitted to GambleCodez for manual review. Please allow 24-48 hours.\n\n'
+ + `Attempts used: ${wb.attempts}/3 (${attemptsLeft} remaining).`,
);
- const handle = user.tgUsername ? `@${user.tgUsername}` : `user${user.id}`;
- await notifyAdminsPlain(
- `๐ฏ *New 30 SC bonus request โ admin action required*\n`
- + `From: ${handle} (TG ID: ${user.id})\n`
- + `Runewager: ${user.runewagerUsername || '(not linked)'}\n`
- + `7-day wager declared: ${wb.wager7dayTotal} SC\n`
- + `Attempts: ${wb.attempts}/3\n`
- + `Status: pending\n`
- + `\nReview with: /bonus pending`,
- );
+ appendBonusAdminLog(user.id, 'bonus_request_submitted', `attempt=${wb.attempts}`);
+ await notifyAdminsPlain(`User ${user.id} requested 30 SC bonus. Manual review required.`);
}
/**
@@ -2424,6 +2461,8 @@ async function showBonusStatus(ctx, user) {
'',
`Attempts used: ${wb.attempts}/3 (${attemptsLeft} remaining)`,
`Status: ${statusLabels[wb.status] || wb.status}`,
+ wb.approvedAt ? `Approved at: ${new Date(wb.approvedAt).toISOString()}` : '',
+ wb.updatedAt ? `Last review update: ${new Date(wb.updatedAt).toISOString()}` : '',
`Runewager username: ${user.runewagerUsername || '(not linked)'}`,
`Affiliate: ${wb.affiliateSource || 'GambleCodez'}`,
`Wager on file: ${wagerDisplay}`,
@@ -2513,10 +2552,8 @@ function promoButton() {
*/
-function miniAppPlayButton(user = null, route = 'play') {
- const target = getPlayLink(user, route);
- const inBrowser = (user && user.settings && user.settings.playMode === 'browser');
- return Markup.button.url(inBrowser ? '๐ Open Runewager (Browser)' : '๐ฎ Open Runewager (Mini App)', target);
+function miniAppPlayButton(user = null) {
+ return playButtonMarkup(user);
}
/**
@@ -2526,8 +2563,6 @@ function miniAppPlayButton(user = null, route = 'play') {
* @param {object} user - user object for settings-aware button labels
*/
function mainMenuKeyboard(isAdminUser = false, page = 1, user = null) {
- const playUrl = getPlayLink(user, 'play');
-
if (page === 2) {
// โโ Page 2 โ Community & Extras โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const rows = [
@@ -2576,7 +2611,7 @@ function mainMenuKeyboard(isAdminUser = false, page = 1, user = null) {
// โโ Page 1 โ Primary Actions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const rows = [
// Primary CTA โ full width
- [Markup.button.url('๐ฎ Play Runewager', playUrl)],
+ [playButtonMarkup(user)],
// Account category
[
Markup.button.callback('โ
Create / Verify Account', 'menu_verify_account'),
@@ -3253,6 +3288,7 @@ function adminDashboardKeyboard(page = 1) {
],
[
Markup.button.callback('๐ Stats', 'admin_stats_menu'),
+ Markup.button.callback('30 SC Bonus Admin', 'w30_admin_menu'),
Markup.button.callback('โถ๏ธ Quick Actions', 'admin_dash_page_2'),
],
[Markup.button.callback('โฌ
๏ธ Back to User Menu', 'to_main_menu')],
@@ -3756,10 +3792,8 @@ async function sendMainMenu(ctx, user, page = 1) {
/** Keyboard for the new persistent USER MAIN MENU */
function userMainMenuKeyboard(isAdminUser, user = null) {
- const playUrl = getPlayLink(user, 'play');
- const browserMode = user && user.settings && user.settings.playMode === 'browser';
const rows = [
- [Markup.button.url(browserMode ? '๐ Play Runewager (Browser)' : '๐ฎ Play Runewager', playUrl)],
+ [playButtonMarkup(user)],
[
Markup.button.callback('๐ Bonuses & Rewards', 'pmenu_claim_bonus'),
Markup.button.callback('๐ค Profile & Link', 'pmenu_my_profile'),
@@ -5359,6 +5393,7 @@ bot.command('menu', async (ctx) => {
const user = getUser(ctx);
clearPendingAction(user);
if (!user.ageConfirmed) {
+ await clearOldMenus(ctx, user);
await ctx.reply(COPY.ageGate, { parse_mode: 'Markdown', ...ageGateKeyboard() });
return;
}
@@ -5368,6 +5403,7 @@ bot.command('menu', async (ctx) => {
bot.command('help', async (ctx) => {
const user = getUser(ctx);
clearPendingAction(user);
+ await clearOldMenus(ctx, user);
await sendHelpMenu(ctx, user, 1);
});
@@ -5375,12 +5411,14 @@ bot.command('help', async (ctx) => {
bot.command('commands', async (ctx) => {
const user = getUser(ctx);
clearPendingAction(user);
+ await clearOldMenus(ctx, user);
await sendHelpMenu(ctx, user, 1);
});
bot.command('settings', async (ctx) => {
const user = getUser(ctx);
clearPendingAction(user);
+ await clearOldMenus(ctx, user);
await sendSettingsMenu(ctx, user);
});
@@ -5884,6 +5922,7 @@ bot.command('admin', async (ctx) => {
return;
}
const user = getUser(ctx);
+ await clearOldMenus(ctx, user);
persistAdminMode(user, true);
await refreshAdminMenuHeader(ctx, user);
const isGroup = ctx.chat && (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup');
@@ -5902,6 +5941,7 @@ bot.command('admin', async (ctx) => {
bot.command('sshv', async (ctx) => {
if (!requireAdmin(ctx)) return;
const user = getUser(ctx);
+ await clearOldMenus(ctx, user);
persistAdminMode(user, true);
await refreshAdminMenuHeader(ctx, user);
const session = getSshvSession(user.id, { createIfMissing: true });
@@ -6697,6 +6737,7 @@ bot.action('ref_leaderboard', async (ctx) => {
bot.command('on', async (ctx) => {
if (!requireAdmin(ctx)) return;
const user = getUser(ctx);
+ await clearOldMenus(ctx, user);
persistAdminMode(user, true);
await refreshAdminMenuHeader(ctx, user);
await ctx.reply('๐ง Admin Mode Enabled');
@@ -6863,7 +6904,7 @@ bot.action('pmenu_claim_bonus', async (ctx) => {
await replaceCallbackPanel(ctx,
'๐ *Bonus Options*\n\n'
+ 'โข *New User Bonus* โ Claim signup promo code (once per account)\n'
- + 'โข *30 SC Wager Bonus* โ Submit your wager proof for 30 SC',
+ + 'โข *30 SC Bonus* โ Manual review request flow',
{
parse_mode: 'Markdown',
...Markup.inlineKeyboard([
@@ -7016,6 +7057,8 @@ bot.action('settings_toggle_playmode', async (ctx) => {
const user = getUser(ctx);
await ctx.answerCbQuery();
user.settings.playMode = user.settings.playMode === 'browser' ? 'miniapp' : 'browser';
+ persistRuntimeState();
+ await clearOldMenus(ctx, user);
await sendSettingsMenu(ctx, user);
});
@@ -7105,7 +7148,7 @@ ${lines.join('\n')}`, Markup.inlineKeyboard([[Markup.button.callback('๐ Group
bot.action('menu_qc_play', async (ctx) => {
const user = getUser(ctx);
await ctx.answerCbQuery();
- await ctx.reply('๐ฎ Open Runewager:', Markup.inlineKeyboard([[miniAppPlayButton(user)], [Markup.button.callback('โฌ
๏ธ Main Menu', 'to_main_menu')]]));
+ await ctx.reply('๐ฎ Open Runewager:', Markup.inlineKeyboard([[playButtonMarkup(user)], [Markup.button.callback('โฌ
๏ธ Main Menu', 'to_main_menu')]]));
});
bot.action('menu_qc_profile', async (ctx) => {
@@ -7122,7 +7165,49 @@ bot.action('menu_qc_status', async (ctx) => {
bot.action('w30_request_start', async (ctx) => {
const user = getUser(ctx);
await ctx.answerCbQuery();
- await showBonusStatus(ctx, user);
+ await ctx.reply(
+ '๐ *30 SC Bonus*\n\nChoose an option:',
+ {
+ parse_mode: 'Markdown',
+ ...Markup.inlineKeyboard([
+ [Markup.button.callback('How It Works', 'w30_menu_how')],
+ [Markup.button.callback('Check My Eligibility', 'w30_menu_eligibility')],
+ [Markup.button.callback('Request My Bonus', 'w30_menu_request')],
+ [Markup.button.callback('Check Bonus Status', 'w30_my_status')],
+ [Markup.button.callback('โฌ
๏ธ Back', 'to_main_menu'), Markup.button.callback('โ Cancel', 'to_main_menu')],
+ ]),
+ },
+ );
+});
+
+bot.action('w30_menu_how', async (ctx) => {
+ await ctx.answerCbQuery();
+ await ctx.reply(
+ 'You can request a one-time 30 SC bonus after wagering 3,000 SC in any rolling 7-day period. '
+ + 'When you believe you\'ve met the requirement, tap Request My Bonus. '
+ + 'The bot will forward your request to GambleCodez for manual review. No screenshots needed. '
+ + 'Reviews take 24โ48 hours. Once per account/IP.',
+ Markup.inlineKeyboard([[Markup.button.callback('โฌ
๏ธ Back', 'w30_request_start')], [Markup.button.callback('โ Cancel', 'to_main_menu')]]),
+ );
+});
+
+bot.action('w30_menu_eligibility', async (ctx) => {
+ await ctx.answerCbQuery();
+ await ctx.reply(
+ 'Eligibility is reviewed manually by GambleCodez.\n\nโข You may request once per account/IP.\nโข Admin review takes 24โ48 hours.',
+ Markup.inlineKeyboard([[Markup.button.callback('โฌ
๏ธ Back', 'w30_request_start')], [Markup.button.callback('โ Cancel', 'to_main_menu')]]),
+ );
+});
+
+bot.action('w30_menu_request', async (ctx) => {
+ const user = getUser(ctx);
+ await ctx.answerCbQuery();
+ const check = checkBonusEligibility(user);
+ if (!check.ok) {
+ await ctx.reply(check.reason, Markup.inlineKeyboard([[Markup.button.callback('โฌ
๏ธ Back', 'w30_request_start')]]));
+ return;
+ }
+ await submitBonusRequest(ctx, user);
});
bot.action('w30_bonus_info', async (ctx) => {
@@ -7585,6 +7670,7 @@ bot.action('menu_verify_account', async (ctx) => {
const user = getUser(ctx);
clearPendingAction(user);
if (!user.ageConfirmed) {
+ await clearOldMenus(ctx, user);
await ctx.reply(COPY.ageGate, { parse_mode: 'Markdown', ...ageGateKeyboard() });
return;
}
@@ -7792,6 +7878,7 @@ bot.action('menu_claim_bonus', async (ctx) => {
clearPendingAction(user);
await ctx.answerCbQuery();
if (!user.ageConfirmed) {
+ await clearOldMenus(ctx, user);
await ctx.reply(COPY.ageGate, { parse_mode: 'Markdown', ...ageGateKeyboard() });
return;
}
@@ -8381,6 +8468,7 @@ bot.action('admin_cmd_mode_toggle', async (ctx) => {
bot.action('admin_cmd_mode_on', async (ctx) => {
if (!requireAdmin(ctx)) return;
const user = getUser(ctx);
+ await clearOldMenus(ctx, user);
persistAdminMode(user, true);
await ctx.answerCbQuery('Admin mode enabled');
await refreshAdminMenuHeader(ctx, user);
@@ -8438,6 +8526,7 @@ bot.action('sshv_run_prompt', async (ctx) => {
bot.action('sshv_open', async (ctx) => {
if (!requireAdmin(ctx)) return;
const user = getUser(ctx);
+ await clearOldMenus(ctx, user);
persistAdminMode(user, true);
const session = getSshvSession(user.id, { createIfMissing: true });
validateAndFixSshvSession(user, session, { onStartup: false });
@@ -8709,6 +8798,7 @@ bot.action('menu_admin_tab', async (ctx) => {
bot.action('admin_auth_bypass', async (ctx) => {
if (!requireAdmin(ctx)) return;
const user = getUser(ctx);
+ await clearOldMenus(ctx, user);
persistAdminMode(user, true);
await refreshAdminMenuHeader(ctx, user);
await ctx.answerCbQuery('Admin mode enabled.');
@@ -8958,6 +9048,25 @@ bot.action('admin_broadcast_yes', async (ctx) => {
// --- Inline keyboard button handlers ---
+bot.action('w30_admin_menu', async (ctx) => {
+ if (!requireAdmin(ctx)) return;
+ await ctx.answerCbQuery();
+ await ctx.reply(
+ '๐ *30 SC Bonus Admin*\n\nManual review tools:',
+ {
+ parse_mode: 'Markdown',
+ ...Markup.inlineKeyboard([
+ [Markup.button.callback('View Pending Requests', 'w30_admin_pending')],
+ [Markup.button.callback('Approve Bonus', 'w30_admin_approve_pick')],
+ [Markup.button.callback('Deny Bonus', 'w30_admin_deny_pick')],
+ [Markup.button.callback('View User History', 'w30_admin_lookup')],
+ [Markup.button.callback('Reset Attempts', 'w30_admin_reset')],
+ [Markup.button.callback('โฌ
๏ธ Back', 'open_admin_dashboard'), Markup.button.callback('โ Cancel', 'to_main_menu')],
+ ]),
+ },
+ );
+});
+
bot.action('w30_admin_pending', async (ctx) => {
if (!requireAdmin(ctx)) return;
await ctx.answerCbQuery();
@@ -12294,12 +12403,14 @@ async function bonusAdminApprove(ctx, rawId) {
const current = target.wagerBonus.status;
if (!isValidBonusTransition(current, 'approved')) throw new Error(`Invalid bonus transition: ${current} -> approved`);
target.wagerBonus.status = 'approved';
+ target.wagerBonus.approvedAt = Date.now();
target.wagerBonus.updatedAt = Date.now();
});
const approvedName = target.tgUsername || `user${target.id}`;
+ appendBonusAdminLog(ctx.from && ctx.from.id, 'bonus_approved', `target=${target.id}`);
await ctx.reply(`โ
Approved. TG ID: ${target.id} (@${approvedName})\n\nNow manually credit the 30 SC on Runewager, then run:\n/bonus sent ${target.id}`);
try {
- await bot.telegram.sendMessage(target.id, 'Your 30 SC bonus request was approved. An admin will manually credit your bonus on Runewager shortly.');
+ await bot.telegram.sendMessage(target.id, 'Your 30 SC bonus has been approved and will be added by GambleCodez.');
} catch (e) { /* ignore */ }
}
@@ -12335,9 +12446,10 @@ async function bonusAdminDeny(ctx, rawId, reason) {
target.wagerBonus.updatedAt = Date.now();
});
const deniedName = target.tgUsername || `user${target.id}`;
+ appendBonusAdminLog(ctx.from && ctx.from.id, 'bonus_denied', `target=${target.id};reason=${target.wagerBonus.denyReason}`);
await ctx.reply(`โ Denied. TG ID: ${target.id} (@${deniedName})\nReason: ${target.wagerBonus.denyReason}`);
try {
- await bot.telegram.sendMessage(target.id, `Your 30 SC bonus request was denied.\nReason: ${target.wagerBonus.denyReason}`);
+ await bot.telegram.sendMessage(target.id, `Your 30 SC bonus request was denied: ${target.wagerBonus.denyReason}`);
} catch (e) { /* ignore */ }
}
diff --git a/load_tooltips.sh b/load_tooltips.sh
new file mode 100755
index 0000000..0d3a535
--- /dev/null
+++ b/load_tooltips.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+OUT="/var/www/html/Runewager/data/tooltips.json"
+mkdir -p "$(dirname "$OUT")"
+
+cat > "$OUT" <<'JSON'
+[
+ {"id":1,"text":"Welcome to Runewager โ everything here is free to play with prize redemptions.","enabled":true},
+ {"id":2,"text":"Tap ๐ต Play & Win to launch using your current Play Mode setting.","enabled":true},
+ {"id":3,"text":"Use Settings to switch between Browser mode and Mini App mode anytime.","enabled":true},
+ {"id":4,"text":"Link your username early so bonuses and giveaways can be tracked correctly.","enabled":true},
+ {"id":5,"text":"The 30 SC Bonus is reviewed manually by GambleCodez โ no screenshots required.","enabled":true},
+ {"id":6,"text":"Need help? Open Help / Commands from the main menu.","enabled":true},
+ {"id":7,"text":"Join community hubs for updates: Channel and Group.","enabled":true},
+ {"id":8,"text":"Referral boosts can grant a 2ร giveaway boost for 7 days.","enabled":true},
+ {"id":9,"text":"All Discord actions open externally; this bot does not use Discord APIs.","enabled":true},
+ {"id":10,"text":"Use /menu if you need to reset navigation and reopen the persistent menu.","enabled":true},
+ {"id":11,"text":"Admins can use /testall to run full diagnostics and health checks.","enabled":true},
+ {"id":12,"text":"Group Linking Tools are in Settings for linking, viewing, removing, and permission tests.","enabled":true},
+ {"id":13,"text":"Bonus requests are manual and limited by policy; keep your account details accurate.","enabled":true},
+ {"id":14,"text":"Use Cancel or Back buttons to safely exit any pending flow.","enabled":true},
+ {"id":15,"text":"Runewager remains 100% free to play with worldwide access.","enabled":true}
+]
+JSON
+
+python -m json.tool "$OUT" >/dev/null
+echo "โ
Tooltips loaded successfully into $OUT"
diff --git a/test/smoke.test.js b/test/smoke.test.js
index f68b91e..5455250 100644
--- a/test/smoke.test.js
+++ b/test/smoke.test.js
@@ -552,11 +552,67 @@ test('to_main_menu clears old menus before rendering persistent menu', () => {
assert.ok(block.includes('await sendPersistentUserMenu(ctx, user);'), 'Expected sendPersistentUserMenu in to_main_menu');
});
-test('persistent user menu play button uses settings-aware getPlayLink', () => {
+test('play button helper is deterministic for browser and mini app modes', () => {
const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8');
- const start = source.indexOf('function userMainMenuKeyboard(');
- assert.ok(start >= 0, 'Expected userMainMenuKeyboard');
+ assert.ok(source.includes('function getPlayButton('), 'Expected unified getPlayButton helper');
+ const helperStart = source.indexOf('function getPlayButton(');
+ const helperBlock = source.slice(helperStart, helperStart + 650);
+ assert.ok(helperBlock.includes("url: 'https://runewager.com/r/GambleCodez'"), 'Expected browser mode external URL');
+ assert.ok(helperBlock.includes("web_app: { url: 'https://t.me/RuneWager_bot/Play' }"), 'Expected mini app web_app launch');
+ assert.ok(!helperBlock.includes('startapp='), 'Expected no startapp launch in getPlayButton helper');
+});
+
+test('all menu play buttons use unified getPlayButton path with no hardcoded mixed mode', () => {
+ const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8');
+ assert.ok(source.includes('function playButtonMarkup('), 'Expected helper to convert getPlayButton to Markup button');
+
+ const playButtonUsageCount = (source.match(/\[playButtonMarkup\(user\)\]/g) || []).length;
+ assert.ok(playButtonUsageCount >= 2, 'Expected both main and persistent menus to use unified play button helper');
+
+ const quickStart = source.indexOf("bot.action('menu_qc_play'");
+ assert.ok(quickStart >= 0, 'Expected quick-play callback');
+ const quickBlock = source.slice(quickStart, quickStart + 350);
+ assert.ok(quickBlock.includes('playButtonMarkup(user)'), 'Expected quick-play to use unified play button helper');
+});
+
+test('settings play mode toggle persists, clears old menus, and re-renders settings', () => {
+ const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8');
+ const start = source.indexOf("bot.action('settings_toggle_playmode'");
+ assert.ok(start >= 0, 'Expected settings_toggle_playmode callback');
const block = source.slice(start, start + 500);
- assert.ok(block.includes("getPlayLink(user, 'play')"), 'Expected settings-aware play link in persistent menu');
- assert.ok(block.includes('Play Runewager (Browser)'), 'Expected browser-mode label for persistent play button');
+ assert.ok(block.includes('persistRuntimeState();'), 'Expected play mode toggle to persist immediately');
+ assert.ok(block.includes('await clearOldMenus(ctx, user);'), 'Expected play mode toggle to clear stale menus before re-render');
+ assert.ok(block.includes('await sendSettingsMenu(ctx, user);'), 'Expected play mode toggle to re-render settings menu');
+});
+
+
+test('load_tooltips.sh exists and targets system tooltip json path', () => {
+ const script = fs.readFileSync(path.resolve(__dirname, '..', 'load_tooltips.sh'), 'utf8');
+ assert.ok(script.includes('/var/www/html/Runewager/data/tooltips.json'), 'Expected tooltips output path');
+ assert.ok(script.includes('โ
Tooltips loaded successfully'), 'Expected success message');
+});
+
+test('30 SC user submenu includes required manual-review actions and copy', () => {
+ const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8');
+ for (const token of ['w30_menu_how', 'w30_menu_eligibility', 'w30_menu_request', 'w30_my_status']) {
+ assert.ok(source.includes(token), `Expected 30 SC menu callback: ${token}`);
+ }
+ assert.ok(source.includes('Eligibility is reviewed manually by GambleCodez.'), 'Expected manual eligibility copy');
+ assert.ok(source.includes('Your request has been submitted to GambleCodez for manual review.'), 'Expected request submission copy');
+});
+
+test('30 SC admin submenu and log sink are wired', () => {
+ const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8');
+ assert.ok(source.includes("bot.action('w30_admin_menu'"), 'Expected admin bonus submenu callback');
+ for (const label of ['View Pending Requests', 'Approve Bonus', 'Deny Bonus', 'View User History', 'Reset Attempts']) {
+ assert.ok(source.includes(label), `Expected admin bonus menu label: ${label}`);
+ }
+ assert.ok(source.includes('/var/www/html/Runewager/logs/bonus_admin.log'), 'Expected bonus admin log file path');
+});
+
+test('BOTPRIVACYMODE compatibility env var is removed', () => {
+ const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8');
+ const envExample = fs.readFileSync(path.resolve(__dirname, '..', '.env.example'), 'utf8');
+ assert.ok(!source.includes('BOTPRIVACYMODE'), 'Expected BOTPRIVACYMODE removed from runtime env parsing');
+ assert.ok(!envExample.includes('BOTPRIVACYMODE'), 'Expected BOTPRIVACYMODE removed from .env.example');
});
diff --git a/test/unit.test.js b/test/unit.test.js
index 09d1810..aa7d322 100644
--- a/test/unit.test.js
+++ b/test/unit.test.js
@@ -237,12 +237,17 @@ function getDiscordLink(url) {
return 'https://discord.gg/runewagers';
}
+function getPlayMode(user = null) {
+ const mode = user && user.settings ? user.settings.playMode : user && user.playMode;
+ return mode === 'browser' ? 'browser' : 'miniapp';
+}
+
function getBrowserLink(route = 'play') {
const map = {
- play: 'https://gamble-codez.com/runewager',
- profile: 'https://gamble-codez.com/runewager/profile',
- claim: 'https://gamble-codez.com/runewager/claim',
- affiliate: 'https://gamble-codez.com/runewager/affiliate',
+ play: 'https://runewager.com/r/GambleCodez',
+ profile: 'https://www.runewager.com/profile',
+ claim: 'https://www.runewager.com/affiliate',
+ affiliate: 'https://www.runewager.com/affiliate',
};
return map[route] || map.play;
}
@@ -252,8 +257,20 @@ function getStartAppLink(route = 'play') {
}
function getPlayLink(user, route = 'play') {
- const mode = user && user.settings ? user.settings.playMode : 'miniapp';
- return mode === 'browser' ? getBrowserLink(route) : getStartAppLink(route);
+ return getPlayMode(user) === 'browser' ? getBrowserLink(route) : getStartAppLink(route);
+}
+
+function getPlayButton(user = null) {
+ if (getPlayMode(user) === 'browser') {
+ return {
+ text: '๐ต Play & Win',
+ url: 'https://runewager.com/r/GambleCodez',
+ };
+ }
+ return {
+ text: '๐ต Play & Win',
+ web_app: { url: 'https://t.me/RuneWager_bot/Play' },
+ };
}
// ---------------------------------------------------------------------------
@@ -449,21 +466,33 @@ test('pending action timeout uses fallback type label when missing', () => {
-test('browser mode returns external gamble-codez link only', () => {
+test('browser mode returns external runewager affiliate link only for play', () => {
const user = { settings: { playMode: 'browser' } };
const out = getPlayLink(user, 'play');
- assert.match(out, /^https:\/\/gamble-codez\.com\//);
+ assert.equal(out, 'https://runewager.com/r/GambleCodez');
assert.ok(!out.includes('startapp='));
assert.ok(!out.includes('t.me/'));
});
-test('telegram mode returns startapp mini-app link only', () => {
+test('mini app mode returns startapp mini-app link for routes', () => {
const user = { settings: { playMode: 'miniapp' } };
const out = getPlayLink(user, 'profile');
assert.match(out, /^https:\/\/t\.me\//);
assert.ok(out.includes('startapp=profile'));
});
+test('getPlayButton enforces deterministic browser and mini app launch metadata', () => {
+ const browser = getPlayButton({ settings: { playMode: 'browser' } });
+ assert.equal(browser.text, '๐ต Play & Win');
+ assert.equal(browser.url, 'https://runewager.com/r/GambleCodez');
+ assert.equal(browser.web_app, undefined);
+
+ const mini = getPlayButton({ settings: { playMode: 'miniapp' } });
+ assert.equal(mini.text, '๐ต Play & Win');
+ assert.deepEqual(mini.web_app, { url: 'https://t.me/RuneWager_bot/Play' });
+ assert.equal(mini.url, undefined);
+});
+
test('discord links remain external and unwrap telegram wrappers', () => {
const wrapped = 'https://t.me/iv?url=https%3A%2F%2Fdiscord.gg%2Frunewagers';
const out = getDiscordLink(wrapped);
@@ -550,3 +579,19 @@ test('valid referral applies boost expiry shape', () => {
assert.equal(out.ownerId, 1);
assert.ok(out.expiresAt > 1700000000000);
});
+
+
+test('manual bonus policy allows requests without auto wager/API checks', () => {
+ function checkBonusEligibilityLike(user) {
+ if (user.needsUsername) return { ok: false };
+ if (user.status === 'bonus_sent') return { ok: false };
+ if (user.status === 'pending' || user.status === 'approved') return { ok: false };
+ if (user.attempts >= 3) return { ok: false };
+ return { ok: true };
+ }
+
+ assert.equal(checkBonusEligibilityLike({ status: 'none', attempts: 0 }).ok, true);
+ assert.equal(checkBonusEligibilityLike({ status: 'denied', attempts: 2 }).ok, true);
+ assert.equal(checkBonusEligibilityLike({ status: 'bonus_sent', attempts: 1 }).ok, false);
+ assert.equal(checkBonusEligibilityLike({ status: 'none', attempts: 3 }).ok, false);
+});