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..e703052 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ data/helpful_messages.json data/promo-history.json data/runtime-state.json data/sshv-sessions.json + +# Runtime generated data + backups +data/*.json +data/backups/** diff --git a/index.js b/index.js index cb44840..10db4d2 100644 --- a/index.js +++ b/index.js @@ -208,6 +208,11 @@ const tipsStore = { lastSentTipId: null, }; +const broadcastConfigStore = { + targetChannel: ANNOUNCE_CHANNEL, + targetGroup: TIPS_GROUP, +}; + let tipsTimerRef = null; const dataDir = path.join(__dirname, 'data'); @@ -531,6 +536,10 @@ function createRuntimeStateSnapshot() { targetGroup: tipsStore.targetGroup, nextTipId: tipsStore.nextTipId, }, + broadcastConfigStore: { + targetChannel: broadcastConfigStore.targetChannel, + targetGroup: broadcastConfigStore.targetGroup, + }, }; } @@ -700,6 +709,15 @@ 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() { @@ -1217,6 +1235,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) { @@ -1258,7 +1302,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')], ]), @@ -1321,6 +1365,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 }); @@ -1868,6 +1916,7 @@ 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('๐Ÿค– Verify Bot Setup', 'admin_cmd_verify_setup')], [Markup.button.callback('๐Ÿงช Tip Test (4h broadcast)', 'admin_cmd_tiptest')], [Markup.button.callback('๐Ÿ’พ Backup State', 'admin_backup_action')], [Markup.button.callback(toggleLabel, 'admin_cmd_mode_toggle')], @@ -2022,18 +2071,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); } @@ -2046,11 +2096,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` ); } @@ -2095,10 +2146,18 @@ async function sendPersistentUserMenu(ctx, user) { function adminMainMenuKeyboard(user) { const toggleLabel = user && user.adminModeOn ? '๐ŸŸข Admin Mode: ON (tap to toggle)' : 'โšช Admin Mode: OFF (tap to toggle)'; return Markup.inlineKeyboard([ - [Markup.button.callback('๐Ÿ‘ค Users & Stats', 'pamenu_stats')], - [Markup.button.callback('๐ŸŽ‰ Giveaways', 'pamenu_active_giveaways')], - [Markup.button.callback('๐ŸŽ› Promo Manager', '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('๐Ÿ“ฃ Announce & Tips', 'admin_cmd_announce_start'), + ], + [ + Markup.button.callback('โš™๏ธ System & Health', 'admin_cat_system'), + Markup.button.callback('๐Ÿ“„ Admin Help', 'help_tab_admin'), + ], [Markup.button.callback(toggleLabel, 'admin_cmd_mode_toggle')], [Markup.button.callback('โ†ฉ Back to User Menu', 'pamenu_back_user')], ]); @@ -2609,12 +2668,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). @@ -3482,12 +3542,70 @@ bot.command('sshv', async (ctx) => { // โ†’ Send Channel Only โ†’ channel โ†’ state.none // ========================= +function parseModeLabel(mode) { + return mode === 'HTML' ? 'HTML' : 'Text'; +} + +function announceBuilderKeyboard(config) { + const dm = config.sendDm ? 'โœ… DM Users' : 'โ˜ DM Users'; + const ch = config.sendChannel ? 'โœ… Channel' : 'โ˜ Channel'; + const gr = config.sendGroup ? 'โœ… Group' : 'โ˜ Group'; + return Markup.inlineKeyboard([ + [Markup.button.callback(dm, 'announce_toggle_dm'), Markup.button.callback(ch, 'announce_toggle_channel')], + [Markup.button.callback(gr, 'announce_toggle_group'), Markup.button.callback(`Mode: ${parseModeLabel(config.parseMode)}`, 'announce_toggle_mode')], + [Markup.button.callback('โœ๏ธ Edit Text', 'announce_edit')], + [Markup.button.callback('๐Ÿš€ Send Broadcast Now', 'announce_send_now')], + [Markup.button.callback('โŒ Cancel', 'admin_cancel')], + ]); +} + +async function sendAnnouncementTargets(ctx, announcementText, config) { + let sentDm = 0; + let failedDm = 0; + const sendOpts = config.parseMode === 'HTML' ? { parse_mode: 'HTML' } : {}; + + if (config.sendDm) { + for (const [, u] of userStore) { + if (u.optOutBroadcasts || u.muted) continue; + try { + // eslint-disable-next-line no-await-in-loop + await ctx.telegram.sendMessage(u.id, announcementText, sendOpts); + sentDm += 1; + } catch (_) { + failedDm += 1; + } + } + } + + let channelStatus = 'skipped'; + if (config.sendChannel && config.targetChannel) { + try { + await ctx.telegram.sendMessage(config.targetChannel, announcementText, sendOpts); + channelStatus = `sent (${config.targetChannel})`; + } catch (e) { + channelStatus = `failed (${e.message})`; + } + } + + let groupStatus = 'skipped'; + if (config.sendGroup && config.targetGroup) { + try { + await ctx.telegram.sendMessage(config.targetGroup, announcementText, sendOpts); + groupStatus = `sent (${config.targetGroup})`; + } catch (e) { + groupStatus = `failed (${e.message})`; + } + } + + return { sentDm, failedDm, channelStatus, groupStatus }; +} + async function handleAnnounceCommand(ctx) { if (!requireAdmin(ctx)) return; const user = getUser(ctx); clearPendingAction(user); user.pendingAction = { type: 'await_announcement_text' }; - await ctx.reply('Okay! What would you like to announce?\nYou can send normal text or HTML.'); + await ctx.reply('Okay! What would you like to announce?\nYou can send normal text or HTML. After sending text, choose targets (DM/channel/group).'); } bot.command('A', handleAnnounceCommand); @@ -3834,8 +3952,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 })); @@ -4336,7 +4458,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([ @@ -4355,6 +4477,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(); @@ -4369,6 +4503,124 @@ bot.action('help_open_bugreport', async (ctx) => { ); }); + +// โ”€โ”€ Legacy/shortcut callback aliases to keep menu flows deterministic โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +bot.action('menu_help', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + await sendHelpMenu(ctx, user, 1); +}); + +bot.action('open_help', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + await sendHelpMenu(ctx, user, 1); +}); + +bot.action('help_tab_admin', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + await sendHelpMenu(ctx, user, 6); +}); + +bot.action('menu_settings_tab', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + await sendSettingsMenu(ctx, user); +}); + +bot.action('settings_toggle_playmode', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + user.settings.playMode = user.settings.playMode === 'browser' ? 'miniapp' : 'browser'; + await sendSettingsMenu(ctx, user); +}); + +bot.action('settings_toggle_quick_commands', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + user.settings.showQuickCommands = !user.settings.showQuickCommands; + await sendSettingsMenu(ctx, user); +}); + +bot.action('settings_toggle_tooltips', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + user.settings.tooltipsEnabled = !user.settings.tooltipsEnabled; + await sendSettingsMenu(ctx, user); +}); + +bot.action('menu_qc_play', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + await ctx.reply('๐ŸŽฎ Open Runewager:', Markup.inlineKeyboard([[miniAppPlayButton()], [Markup.button.callback('โฌ…๏ธ Main Menu', 'to_main_menu')]])); +}); + +bot.action('menu_qc_profile', async (ctx) => { + await ctx.answerCbQuery(); + await ctx.reply('๐Ÿ‘ค Open your profile in the Runewager mini app:', Markup.inlineKeyboard([[Markup.button.url('๐Ÿ‘ค Open Profile', LINKS.miniAppProfile)], [Markup.button.callback('โฌ…๏ธ Main Menu', 'to_main_menu')]])); +}); + +bot.action('menu_qc_status', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + await ctx.reply(buildUserStatusLine(user), Markup.inlineKeyboard([[Markup.button.callback('โฌ…๏ธ Main Menu', 'to_main_menu')]])); +}); + +bot.action('w30_request_start', async (ctx) => { + const user = getUser(ctx); + await ctx.answerCbQuery(); + await showBonusStatus(ctx, user); +}); + +bot.action('w30_bonus_info', async (ctx) => { + await ctx.answerCbQuery(); + await ctx.reply( + `๐ŸŽฏ *30 SC Wager Bonus*\n\nโ€ข One-time bonus\nโ€ข Requires 3,000 SC wager in a rolling 7-day period\nโ€ข Manual admin review/credit`, + { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('๐ŸŽฏ Request Bonus', 'w30_request_start')], [Markup.button.callback('โฌ…๏ธ Main Menu', 'to_main_menu')]]) }, + ); +}); + +bot.action('w30_rules', async (ctx) => { + await ctx.answerCbQuery(); + await ctx.reply( + `๐Ÿ“‹ *30 SC Bonus Rules*\n\n1) Link your Runewager username\n2) Stay under GambleCodez affiliate\n3) Wager at least 3,000 SC in 7-day rolling window\n4) Submit request via the bonus button\n5) Admin manually verifies and credits`, + { parse_mode: 'Markdown', ...Markup.inlineKeyboard([[Markup.button.callback('๐ŸŽฏ Request Bonus', 'w30_request_start')], [Markup.button.callback('โฌ…๏ธ Main Menu', 'to_main_menu')]]) }, + ); +}); + +bot.action('admin_cmd_tips_dashboard', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await handleTipsCommand(ctx); +}); + +bot.action('admin_cmd_announce_start', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await handleAnnounceCommand(ctx); +}); + +bot.action('admin_cmd_tiptest', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await ctx.reply('Use /tiptest to send a test tip to the configured group.'); +}); + +bot.action('admin_broadcast', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery(); + await replaceCallbackPanel(ctx, 'Legacy promo broadcast is disabled. Use Announcements instead.', { + ...Markup.inlineKeyboard([[Markup.button.callback('๐Ÿ“ฃ Announcements', 'admin_cmd_announce_start')], [Markup.button.callback('โฌ…๏ธ Promo Tools', 'admin_cat_promo')]]), + }); +}); + +bot.action('admin_cancel', async (ctx) => { + if (!requireAdmin(ctx)) return; + await ctx.answerCbQuery('Cancelled'); + await sendPersistentAdminMenu(ctx, getUser(ctx)); +}); + bot.action('pmenu_admin', async (ctx) => { const user = getUser(ctx); await ctx.answerCbQuery(); @@ -4550,10 +4802,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)); @@ -5177,6 +5432,12 @@ bot.action('admin_cmd_version', async (ctx) => { await ctx.reply('Use /version to view bot version info.'); }); +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) => { if (!requireAdmin(ctx)) return; await ctx.answerCbQuery('Creating backup...'); @@ -5292,8 +5553,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(); @@ -5308,8 +5571,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(); @@ -5385,14 +5650,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) => { @@ -5400,6 +5674,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.'; @@ -5700,8 +5975,8 @@ bot.action('admin_edit_limit', async (ctx) => { if (!requireAdmin(ctx)) return; bot.action('admin_broadcast_yes', async (ctx) => { if (!requireAdmin(ctx)) return; - await ctx.answerCbQuery('Broadcast disabled'); - await ctx.reply('Legacy promo broadcasts are disabled. Use announcements and Promo Manager cards instead.'); + await ctx.answerCbQuery(); + await handleAnnounceCommand(ctx); }); // ========================= // 30 SC Wager Bonus Admin callbacks @@ -6185,9 +6460,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')], ]), @@ -6196,6 +6471,29 @@ bot.on('text', async (ctx) => { return; } + if (action.type === 'await_register_chat_forward') { + if (!requireAdmin(ctx)) return; + const msg = ctx.message || {}; + const fwdChat = msg.forward_from_chat + || (msg.forward_origin && msg.forward_origin.chat) + || (msg.sender_chat ? msg.sender_chat : null); + if (!fwdChat || !fwdChat.id) { + await ctx.reply('Please forward a message from the target channel/group.'); + return; + } + const chatId = Number(fwdChat.id); + approvedGroupsStore.add(chatId); + tipsStore.targetGroup = String(chatId); + broadcastConfigStore.targetGroup = String(chatId); + user.pendingAction = null; + persistRuntimeState(); + await ctx.reply(`โœ… Registered chat for broadcasts/tips: +โ€ข ${fwdChat.title || chatId} (${chatId}) + +Tips and group broadcasts will now use this target.`); + return; + } + // Admin prompt: whois lookup if (action.type === 'await_admin_whois') { if (!requireAdmin(ctx)) return; @@ -7012,64 +7310,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 // ========================= +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. @@ -7086,20 +7441,13 @@ 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 }); } @@ -7242,17 +7590,15 @@ 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.`); } }); @@ -7321,17 +7667,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); }); @@ -9012,6 +9356,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}`, + `Tips/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])); @@ -9440,7 +9807,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) { @@ -9448,6 +9815,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..8c1f97c 100644 --- a/test/smoke.test.js +++ b/test/smoke.test.js @@ -1,8 +1,40 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); 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 known dynamic patterns)', () => { + const source = fs.readFileSync('index.js', 'utf8'); + const callbackButtons = new Set(Array.from(source.matchAll(/Markup\.button\.callback\([^,]+,\s*'([^']+)'\)/g), (m) => m[1])); + const actionHandlers = new Set(Array.from(source.matchAll(/bot\.action\('([^']+)'/g), (m) => m[1])); + + const dynamicPatterns = [ + /^promo_open_/, + /^promo_claim_/, + /^pamenu_gw_end_/, + /^pamenu_gw_extend_/, + /^pamenu_gw_pause_/, + /^pamenu_gw_resume_/, + /^pamenu_gw_redraw_/, + /^admin_stats_/, + /^admin_dash_page_/, + /^menu_page_/, + /^pmenu_walk_nav_/, + /^walk_/, + /^gw_/, + /^tip_/, + /^tgw_dur_/, + ]; + + const uncovered = Array.from(callbackButtons) + .filter((id) => !actionHandlers.has(id) && !dynamicPatterns.some((pattern) => pattern.test(id))) + .sort(); + + assert.deepEqual(uncovered, [], `Uncovered callback handlers: ${uncovered.join(', ')}`); +}); +