import http from 'node:http'; import { ActionRowBuilder, ButtonStyle, ChannelType, Client, DiscordAPIError, Events, GatewayIntentBits, MessageFlags, ModalBuilder, TextInputBuilder, TextInputStyle, } from 'discord.js'; import { getConfig } from './config.js'; import { commands } from './commands.js'; import { BetStore } from './db.js'; import { parseBetInput } from './parser.js'; import { ALERTS_ALLOWED_USERNAME, ALERTS_CHANNEL_ID, ALERT_ROLE_NAMES, BETS_PAGE_SIZE, } from './constants.js'; import { BRAND_COLORS, buildAlertsButtonRows, buildAlertsEmbed, buildBaseballChartAttachment, buildBankrollEmbed, buildBestMatchupsEmbed, buildBetSavedEmbed, buildBetsEmbed, buildBetsPaginationRow, buildBooksEmbed, buildBulkAddEmbed, buildChartAttachment, buildCommandsEmbed, buildCircaDiagnosticEmbed, buildCircaAlertEmbed, buildCircaFailureEmbed, buildCircaMarketEmbeds, buildCircaMovementEmbed, buildSharpAlertEmbed, buildSharpEdgeBoardEmbed, buildBookScoreboardEmbed, buildMarketHealthEmbed, buildPlayerEdgeEmbed, buildConsensusVsEmbed, buildSteamEmbed, buildStaleBookAlertEmbed, buildReverseAlertEmbed, buildPlayerOddsEmbed, buildOddsApiQuotaEmbed, buildCircaPaginationRow, buildDeleteBetEmbed, buildEditBetEmbed, buildErrorEmbed, buildHrBoardChartEmbed, buildHrProfileEmbed, buildHrTrendEmbed, buildHrValueChartEmbed, buildHrZoneEmbed, buildKCountEmbed, buildKLadderEmbed, buildKMatchupEmbed, buildKProfileEmbed, buildKTrendEmbed, buildPitcherApproachEmbed, buildPitcherArsenalEmbed, buildPitcherCompareEmbed, buildPitcherLocationEmbed, buildPitcherTrendEmbed, buildExportAttachment, buildMatchupHealthEmbed, buildMatchupHittersEmbed, buildMatchupPitchersEmbed, buildMarketTopEmbed, buildPlayerContextEmbed, buildResolveAllEmbed, buildResolveEmbed, buildRoiEmbed, buildScanStatusEmbed, buildSportsEmbed, buildSummaryEmbed, buildWelcomeEmbed, parseAlertRoleButtonId, buildBetsPageId, parseBetsPageId, parseCircaPageId, } from './embeds.js'; import { parseBulkAddInput } from './bulk-add.js'; import { parseBetIdList } from './resolve-bulk.js'; import { MarketScanner } from './market-scanner.js'; import { MatchupService } from './matchups.js'; import { createHrBoardChartPng, createHrProfileRadarPng, createHrTrendChartPng, createHrValueScatterPng, createHrZoneOverlayCardPng, createKCountLeverageChartPng, createKLadderChartPng, createKMatchupCardPng, createKProfileRadarPng, createKTrendChartPng, createPitcherApproachChartPng, createPitcherArsenalChartPng, createPitcherCompareChartPng, createPitcherLocationChartPng, createPitcherTrendChartPng, } from './baseball-charts.js'; const BET_MODAL_PREFIX = 'bet-entry-modal'; const BULK_ADD_MODAL_PREFIX = 'bulk-add-modal'; const BET_PROP_INPUT_ID = 'bet-prop'; const BET_ODDS_INPUT_ID = 'bet-odds'; const BET_STAKE_INPUT_ID = 'bet-stake'; const BULK_ADD_LINES_INPUT_ID = 'bulk-add-lines'; async function main() { const config = getConfig(); console.log('Starting ROI bot', { guildId: config.guildId, adminRoleName: config.adminRoleName, port: config.port, commandCount: commands.length, }); const store = new BetStore(config.databaseUrl); await store.initialize(); console.log('Database initialized successfully'); const client = new Client({ intents: [GatewayIntentBits.Guilds], }); const matchupService = new MatchupService(config.matchups, { logger: console, }); const scanner = new MarketScanner({ client, store, config: config.scanner, embeds: { buildMarketTopEmbed, buildCircaAlertEmbed, buildCircaFailureEmbed, buildCircaMarketEmbeds, buildCircaMovementEmbed, buildSharpAlertEmbed, buildSharpEdgeBoardEmbed, buildBookScoreboardEmbed, buildMarketHealthEmbed, buildStaleBookAlertEmbed, buildReverseAlertEmbed, }, logger: console, }); client.__marketScanner = scanner; client.__matchupService = matchupService; matchupService.setOddsProvider(scanner); const healthServer = http.createServer((request, response) => { response.writeHead(200, { 'Content-Type': 'application/json' }); response.end(JSON.stringify({ ok: true, service: 'roi-bet-tracker-bot' })); }); healthServer.listen(config.port, '0.0.0.0', () => { console.log(`Health server listening on port ${config.port}`); }); client.once(Events.ClientReady, async (readyClient) => { console.log(`Logged in as ${readyClient.user.tag}`); if (config.guildId) { const guild = await readyClient.guilds.fetch(config.guildId); await guild.commands.set(commands); await readyClient.application.commands.set([]); console.log(`Registered slash commands for guild ${config.guildId}`); console.log('Cleared stale global slash commands'); } else { await readyClient.application.commands.set(commands); console.log('Registered global slash commands'); } scanner.start(); }); client.on(Events.InteractionCreate, async (interaction) => { try { if (interaction.isChatInputCommand()) { if (shouldDeferImmediately(interaction.commandName)) { try { await interaction.deferReply(); } catch (error) { if (error?.code === 10062 || error?.code === 10008) { interaction.__ackFailed = true; } else { throw error; } } } auditLog('command_start', { command: interaction.commandName, userId: interaction.user.id, guildId: interaction.guildId, }); await handleChatInput(interaction, store, config); return; } if (interaction.isModalSubmit() && interaction.customId.startsWith(`${BET_MODAL_PREFIX}|`)) { await handleBetModal(interaction, store); return; } if (interaction.isModalSubmit() && interaction.customId.startsWith(`${BULK_ADD_MODAL_PREFIX}|`)) { await handleBulkAddModal(interaction, store); return; } if (interaction.isButton()) { await handleButton(interaction, store); } } catch (error) { auditLog('command_error', { command: interaction.isChatInputCommand() ? interaction.commandName : interaction.customId, userId: interaction.user?.id, guildId: interaction.guildId, error: serializeError(error), }); console.error(error); const payload = { embeds: [ buildErrorEmbed( 'Something went wrong', 'The bot hit an unexpected error while handling that request.' ), ], flags: MessageFlags.Ephemeral, }; if (interaction.replied || interaction.deferred) { await interaction.followUp(payload).catch(() => null); } else { await interaction.reply(payload).catch(() => null); } } }); const shutdown = async () => { console.log('Shutting down...'); healthServer.close(); await scanner.stop(); await matchupService.close(); client.destroy(); await store.close(); process.exit(0); }; process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown); await client.login(config.token); } async function handleChatInput(interaction, store, config) { const { commandName } = interaction; if (commandName === 'bet') { await showBetModal(interaction); return; } if (commandName === 'bulkadd') { await showBulkAddModal(interaction); return; } if (commandName === 'editbet') { await handleEditBet(interaction, store); return; } if (commandName === 'deletebet') { await handleDeleteBet(interaction, store); return; } if (commandName === 'resolve') { await handleResolve(interaction, store); return; } if (commandName === 'resolveall') { await handleResolveAll(interaction, store); return; } if (commandName === 'bankroll') { await handleBankroll(interaction, store); return; } if (commandName === 'roi') { await handleRoi(interaction, store); return; } if (commandName === 'bets') { await handleBets(interaction, store, 1, false); return; } if (commandName === 'summary') { await handleSummary(interaction, store); return; } if (commandName === 'books') { await handleBooks(interaction, store); return; } if (commandName === 'sports') { await handleSports(interaction, store); return; } if (commandName === 'export') { await handleExport(interaction, store); return; } if (commandName === 'commands') { await interaction.reply({ embeds: [buildCommandsEmbed()], }); return; } if (commandName === 'scanstatus') { await handleScanStatus(interaction, config); return; } if (commandName === 'oddsquota') { await handleOddsQuota(interaction, config); return; } if (commandName === 'scanrun') { await handleScanRun(interaction, config); return; } if (commandName === 'scanreport') { await handleScanReport(interaction, config); return; } if (commandName === 'circatest') { await handleCircaTest(interaction, config); return; } if (commandName === 'circamarket') { await handleCircaMarket(interaction, config); return; } if (commandName === 'circahr') { await handleCircaShortcut(interaction, config, 'home_runs'); return; } if (commandName === 'circahits') { await handleCircaShortcut(interaction, config, 'hits'); return; } if (commandName === 'circatb') { await handleCircaShortcut(interaction, config, 'total_bases'); return; } if (commandName === 'circarbis') { await handleCircaShortcut(interaction, config, 'rbis'); return; } if (commandName === 'circaruns') { await handleCircaShortcut(interaction, config, 'runs'); return; } if (commandName === 'circasb') { await handleCircaShortcut(interaction, config, 'steals'); return; } if (commandName === 'circahrri') { await handleCircaShortcut(interaction, config, 'hits_runs_rbis'); return; } if (commandName === 'circak') { await handleCircaShortcut(interaction, config, 'pitcher_strikeouts_generic'); return; } if (commandName === 'hrodds') { await handlePlayerOdds(interaction, config, 'batter_home_runs'); return; } if (commandName === 'hitodds') { await handlePlayerOdds(interaction, config, 'batter_hits'); return; } if (commandName === 'tbodds') { await handlePlayerOdds(interaction, config, 'batter_total_bases'); return; } if (commandName === 'rbiodds') { await handlePlayerOdds(interaction, config, 'batter_rbis'); return; } if (commandName === 'runodds') { await handlePlayerOdds(interaction, config, 'batter_runs_scored'); return; } if (commandName === 'sbodds') { await handlePlayerOdds(interaction, config, 'batter_stolen_bases'); return; } if (commandName === 'kodds') { await handlePlayerOdds(interaction, config, 'pitcher_strikeouts'); return; } if (commandName === 'dfsodds') { await handlePlayerOdds(interaction, config, interaction.options.getString('market', true), 'dfs'); return; } if (commandName === 'exchangeodds') { await handlePlayerOdds(interaction, config, interaction.options.getString('market', true), 'exchange'); return; } if (commandName === 'edgeboard') { await handleSharpBoard(interaction, config, 'edgeboard'); return; } if (commandName === 'playeredge') { await handleSharpBoard(interaction, config, 'playeredge'); return; } if (commandName === 'marketedge') { await handleSharpBoard(interaction, config, 'marketedge'); return; } if (commandName === 'widthboard') { await handleSharpBoard(interaction, config, 'widthboard'); return; } if (commandName === 'consensusvs') { await handleSharpBoard(interaction, config, 'consensusvs'); return; } if (commandName === 'dfsedgeboard') { await handleSharpBoard(interaction, config, 'edgeboard', 'dfs'); return; } if (commandName === 'dfsplayeredge') { await handleSharpBoard(interaction, config, 'playeredge', 'dfs'); return; } if (commandName === 'dfsmarketedge') { await handleSharpBoard(interaction, config, 'marketedge', 'dfs'); return; } if (commandName === 'dfswidthboard') { await handleSharpBoard(interaction, config, 'widthboard', 'dfs'); return; } if (commandName === 'dfsconsensusvs') { await handleSharpBoard(interaction, config, 'consensusvs', 'dfs'); return; } if (commandName === 'exchangeedgeboard') { await handleSharpBoard(interaction, config, 'edgeboard', 'exchange'); return; } if (commandName === 'exchangeplayeredge') { await handleSharpBoard(interaction, config, 'playeredge', 'exchange'); return; } if (commandName === 'exchangemarketedge') { await handleSharpBoard(interaction, config, 'marketedge', 'exchange'); return; } if (commandName === 'exchangewidthboard') { await handleSharpBoard(interaction, config, 'widthboard', 'exchange'); return; } if (commandName === 'exchangeconsensusvs') { await handleSharpBoard(interaction, config, 'consensusvs', 'exchange'); return; } if (commandName === 'steam') { await handleSharpBoard(interaction, config, 'steam'); return; } if (commandName === 'sharpboard') { await handleSharpBoard(interaction, config, 'sharpboard'); return; } if (commandName === 'bookscoreboard') { await handleSharpBoard(interaction, config, 'bookscoreboard'); return; } if (commandName === 'markethealth') { await handleSharpBoard(interaction, config, 'markethealth'); return; } if (commandName === 'matchuphitters') { await handleMatchupCommand(interaction, config, 'matchuphitters'); return; } if (commandName === 'matchuppitchers') { await handleMatchupCommand(interaction, config, 'matchuppitchers'); return; } if (commandName === 'playercontext') { await handleMatchupCommand(interaction, config, 'playercontext'); return; } if (commandName === 'bestmatchups') { await handleMatchupCommand(interaction, config, 'bestmatchups'); return; } if (commandName === 'teambestmatchups') { await handleMatchupCommand(interaction, config, 'teambestmatchups'); return; } if (commandName === 'matchuphealth') { await handleMatchupHealth(interaction, config); return; } if ([ 'hrboardchart', 'hrtrend', 'hrprofile', 'hrvaluechart', 'hrzone', 'ktrend', 'kladder', 'kprofile', 'kmatchup', 'kcount', 'pitchertrend', 'pitcherarsenal', 'pitcherlocation', 'pitcherapproach', 'pitchercompare', ].includes(commandName)) { await handleBaseballChartCommand(interaction, config, commandName); return; } if (commandName === 'alerts') { await handleAlerts(interaction); return; } if (commandName === 'welcome') { await handleWelcome(interaction, config); return; } if (commandName === 'bulkdeletemessages') { await handleBulkDeleteMessages(interaction); } } function getAnalyticsFilters(interaction) { return { dateWindow: interaction.options.getString('date_window') ?? undefined, sport: interaction.options.getString('sport') ?? undefined, book: interaction.options.getString('book') ?? undefined, status: interaction.options.getString('status') ?? undefined, }; } function getMarketIntelligenceFilters(interaction) { return { market: interaction.options.getString('market') ?? undefined, book: interaction.options.getString('book') ?? undefined, team: interaction.options.getString('team') ?? undefined, player: interaction.options.getString('player') ?? undefined, minEdge: interaction.options.getNumber('min_edge') ?? undefined, minWidth: interaction.options.getNumber('min_width') ?? undefined, limit: interaction.options.getInteger('limit') ?? undefined, }; } function getMatchupFilters(interaction) { return { team: interaction.options.getString('team') ?? undefined, player: interaction.options.getString('player') ?? undefined, playerType: interaction.options.getString('player_type') ?? undefined, date: interaction.options.getString('date') ?? undefined, limit: interaction.options.getInteger('limit') ?? undefined, }; } function getBaseballChartFilters(interaction) { return { team: interaction.options.getString('team') ?? undefined, player: interaction.options.getString('player') ?? undefined, pitcher: interaction.options.getString('pitcher') ?? undefined, date: interaction.options.getString('date') ?? undefined, limit: interaction.options.getInteger('limit') ?? undefined, window: interaction.options.getString('window') ?? interaction.options.getInteger('window') ?? undefined, book: interaction.options.getString('book') ?? undefined, view: interaction.options.getString('view') ?? undefined, pitchType: interaction.options.getString('pitch_type') ?? undefined, split: interaction.options.getString('split') ?? undefined, compareTo: interaction.options.getString('compare_to') ?? undefined, countBucket: interaction.options.getString('count_bucket') ?? undefined, }; } async function showBetModal(interaction) { const selectedBook = interaction.options.getString('book', true); const selectedSport = interaction.options.getString('sport', true); const modal = new ModalBuilder() .setCustomId(`${BET_MODAL_PREFIX}|${selectedBook}|${selectedSport}`) .setTitle(`Log a Bet - ${selectedBook}`); modal.addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId(BET_PROP_INPUT_ID) .setLabel('Prop / bet') .setPlaceholder('Luka Doncic over 29.5 points') .setRequired(true) .setStyle(TextInputStyle.Paragraph) .setMaxLength(500) ), new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId(BET_ODDS_INPUT_ID) .setLabel('Odds') .setPlaceholder('-110 or 1.91') .setRequired(true) .setStyle(TextInputStyle.Short) .setMaxLength(20) ), new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId(BET_STAKE_INPUT_ID) .setLabel('Stake') .setPlaceholder('$25') .setRequired(true) .setStyle(TextInputStyle.Short) .setMaxLength(20) ) ); await interaction.showModal(modal); } async function showBulkAddModal(interaction) { const selectedBook = interaction.options.getString('book', true); const selectedSport = interaction.options.getString('sport', true); const modal = new ModalBuilder() .setCustomId(`${BULK_ADD_MODAL_PREFIX}|${selectedBook}|${selectedSport}`) .setTitle(`Bulk Add - ${selectedBook}`); modal.addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId(BULK_ADD_LINES_INPUT_ID) .setLabel('One bet per line: prop | odds | stake') .setPlaceholder('Bryan Rocchio 1+ HR | +1450 | $5\n3 leg parlay | +1452 | $5') .setRequired(true) .setStyle(TextInputStyle.Paragraph) .setMaxLength(4000) ) ); await interaction.showModal(modal); } async function handleBetModal(interaction, store) { const [, selectedBook, selectedSport] = interaction.customId.split('|'); const parsed = parseBetInput({ book: selectedBook, sport: selectedSport, prop: interaction.fields.getTextInputValue(BET_PROP_INPUT_ID), odds: interaction.fields.getTextInputValue(BET_ODDS_INPUT_ID), stake: interaction.fields.getTextInputValue(BET_STAKE_INPUT_ID), }); if (!parsed.ok) { await interaction.reply({ embeds: [ buildErrorEmbed( 'Could not parse that bet', `I still need valid values for: **${parsed.missingFields.join(', ')}**.\n\nAccepted odds: \`-110\`, \`+150\`, or decimal like \`1.91\`.\nAccepted stake examples: \`25\` or \`$25\`.` ), ], flags: MessageFlags.Ephemeral, }); return; } const savedBet = await store.createBet(interaction.user, parsed.bet); await interaction.reply({ embeds: [buildBetSavedEmbed(savedBet)], flags: MessageFlags.Ephemeral, }); } async function handleBulkAddModal(interaction, store) { const [, selectedBook, selectedSport] = interaction.customId.split('|'); const rawLines = interaction.fields.getTextInputValue(BULK_ADD_LINES_INPUT_ID); const parsed = parseBulkAddInput(selectedBook, selectedSport, rawLines); if (!parsed.ok) { await interaction.reply({ embeds: [buildErrorEmbed('Could not parse this batch', parsed.error)], flags: MessageFlags.Ephemeral, }); return; } const result = { accepted: [], rejected: [...parsed.rejected], }; for (const entry of parsed.accepted) { try { const savedBet = await store.createBet(interaction.user, entry.bet); result.accepted.push({ lineNumber: entry.lineNumber, savedBet, }); } catch (error) { auditLog('bulkadd_line_error', { userId: interaction.user.id, lineNumber: entry.lineNumber, error: serializeError(error), }); result.rejected.push({ lineNumber: entry.lineNumber, reason: 'Database rejected this line.', }); } } await interaction.reply({ embeds: [buildBulkAddEmbed(selectedBook, selectedSport, result)], flags: MessageFlags.Ephemeral, }); } async function handleEditBet(interaction, store) { const betId = interaction.options.getInteger('bet_id', true); const book = interaction.options.getString('book') ?? undefined; const sport = interaction.options.getString('sport') ?? undefined; const prop = interaction.options.getString('prop') ?? undefined; const odds = interaction.options.getString('odds') ?? undefined; const stakeNumber = interaction.options.getNumber('stake'); if ([book, sport, prop, odds, stakeNumber].every((value) => value === null || value === undefined)) { await interaction.reply({ embeds: [buildErrorEmbed('No changes supplied', 'Provide at least one field to update.')], flags: MessageFlags.Ephemeral, }); return; } const existing = await store.findBet(interaction.user.id, betId); if (!existing) { await interaction.reply({ embeds: [buildErrorEmbed('Bet not found', `I could not find your bet #${betId}.`)], flags: MessageFlags.Ephemeral, }); return; } const parsed = parseBetInput({ book: book ?? existing.book, sport: sport ?? existing.sport, prop: prop ?? existing.prop, odds: odds ?? existing.oddsInput, stake: stakeNumber ?? existing.stake, }); if (!parsed.ok) { await interaction.reply({ embeds: [buildErrorEmbed('Invalid edit', `The updated bet is missing valid: **${parsed.missingFields.join(', ')}**.`)], flags: MessageFlags.Ephemeral, }); return; } const profile = await store.getUserProfile(interaction.user.id); const unitsValue = stakeNumber !== null && profile.unitSize && profile.unitSize > 0 ? Number((parsed.bet.stake / profile.unitSize).toFixed(4)) : undefined; const result = await store.updateBet(interaction.user.id, betId, { book, sport, prop, oddsInput: odds !== undefined ? parsed.bet.oddsInput : undefined, normalizedDecimalOdds: odds !== undefined ? parsed.bet.normalizedDecimalOdds : undefined, stake: stakeNumber !== null ? parsed.bet.stake : undefined, unitsValue, rawInput: parsed.bet.rawInput, }); if (result.type === 'financial_locked') { await interaction.reply({ embeds: [buildErrorEmbed('Settled bet locked', 'Settled bets can still update metadata like sport, book, or prop, but odds and stake stay locked after grading.')], flags: MessageFlags.Ephemeral, }); return; } if (result.type === 'deleted') { await interaction.reply({ embeds: [buildErrorEmbed('Bet deleted', 'Deleted bets cannot be edited.')], flags: MessageFlags.Ephemeral, }); return; } if (result.type === 'no_changes') { await interaction.reply({ embeds: [buildErrorEmbed('No changes applied', 'The edited values matched the existing bet.')], flags: MessageFlags.Ephemeral, }); return; } await interaction.reply({ embeds: [buildEditBetEmbed(result)], flags: MessageFlags.Ephemeral, }); } async function handleDeleteBet(interaction, store) { const betId = interaction.options.getInteger('bet_id', true); const reason = interaction.options.getString('reason'); const result = await store.softDeleteBet(interaction.user.id, betId, reason); if (result.type === 'missing') { await interaction.reply({ embeds: [buildErrorEmbed('Bet not found', `I could not find your bet #${betId}.`)], flags: MessageFlags.Ephemeral, }); return; } if (result.type === 'already_deleted') { await interaction.reply({ embeds: [buildErrorEmbed('Already deleted', `Bet #${betId} is already deleted.`)], flags: MessageFlags.Ephemeral, }); return; } await interaction.reply({ embeds: [buildDeleteBetEmbed(result)], flags: MessageFlags.Ephemeral, }); } async function handleResolve(interaction, store) { const betId = interaction.options.getInteger('bet_id', true); const result = interaction.options.getString('result', true); const resolution = await store.resolveBet(interaction.user.id, betId, result); if (resolution.type === 'missing') { await interaction.reply({ embeds: [buildErrorEmbed('Bet not found', `I could not find your bet #${betId}.`)], flags: MessageFlags.Ephemeral, }); return; } if (resolution.type === 'deleted') { await interaction.reply({ embeds: [buildErrorEmbed('Bet deleted', `Bet #${betId} has been deleted and cannot be graded.`)], flags: MessageFlags.Ephemeral, }); return; } if (resolution.type === 'already_resolved') { await interaction.reply({ embeds: [ buildErrorEmbed( 'Bet already resolved', `Bet #${betId} is already marked as **${resolution.bet.status}**.` ), ], flags: MessageFlags.Ephemeral, }); return; } await interaction.reply({ embeds: [buildResolveEmbed(resolution.bet)], flags: MessageFlags.Ephemeral, }); } async function handleResolveAll(interaction, store) { const betIdsRaw = interaction.options.getString('bet_ids', true); const result = interaction.options.getString('result', true); const parsedIds = parseBetIdList(betIdsRaw); if (!parsedIds.ok) { await interaction.reply({ embeds: [buildErrorEmbed('Invalid bet IDs', parsedIds.error)], flags: MessageFlags.Ephemeral, }); return; } const summary = { resolved: [], alreadyResolved: [], deleted: [], missing: [], }; for (const betId of parsedIds.ids) { const resolution = await store.resolveBet(interaction.user.id, betId, result); if (resolution.type === 'resolved') { summary.resolved.push(betId); continue; } if (resolution.type === 'already_resolved') { summary.alreadyResolved.push(betId); continue; } if (resolution.type === 'deleted') { summary.deleted.push(betId); continue; } summary.missing.push(betId); } await interaction.reply({ embeds: [buildResolveAllEmbed(result, summary)], flags: MessageFlags.Ephemeral, }); } async function handleBankroll(interaction, store) { const startingBankroll = interaction.options.getNumber('starting_bankroll'); const unitSize = interaction.options.getNumber('unit_size'); let profile; if (startingBankroll !== null || unitSize !== null) { if ((startingBankroll !== null && startingBankroll < 0) || (unitSize !== null && unitSize <= 0)) { await interaction.reply({ embeds: [buildErrorEmbed('Invalid bankroll settings', 'Starting bankroll must be 0 or greater and unit size must be above 0.')], flags: MessageFlags.Ephemeral, }); return; } profile = await store.updateUserPerformanceConfig(interaction.user, { startingBankroll: startingBankroll ?? undefined, unitSize: unitSize ?? undefined, }); } else { await store.upsertUser(interaction.user); profile = await store.getUserProfile(interaction.user.id); } const summary = await store.getUserSummary(interaction.user.id); await interaction.reply({ embeds: [buildBankrollEmbed(profile, summary)], flags: MessageFlags.Ephemeral, }); } async function handleRoi(interaction, store) { const filters = getAnalyticsFilters(interaction); const summary = await store.getUserSummary(interaction.user.id, filters); const points = await store.getChartPoints(interaction.user.id, filters); const chartName = 'roi-chart.png'; const chart = await buildChartAttachment(points, chartName, { title: 'ROI Overview', subtitle: 'Profitability trend and staking efficiency over resolved bets', }); await interaction.reply({ embeds: [buildRoiEmbed(summary, points, chartName, filters)], files: [chart], }); } async function handleBets(interaction, store, page = 1, update = false) { const filters = interaction.isButton() ? interaction.__betsFilters : getAnalyticsFilters(interaction); const summary = await store.getUserSummary(interaction.user.id, filters); const betsPage = await store.getRecentBets(interaction.user.id, filters, page, BETS_PAGE_SIZE); const row = buildBetsPaginationRow({ userId: interaction.user.id, page: betsPage.currentPage, totalPages: betsPage.totalPages, ...filters, }); const payload = { embeds: [buildBetsEmbed(summary, betsPage, filters)], components: [row], }; if (update) { await interaction.update(payload); } else { await interaction.reply(payload); } } async function handleSummary(interaction, store) { const filters = getAnalyticsFilters(interaction); const summary = await store.getUserSummary(interaction.user.id, filters); const points = await store.getChartPoints(interaction.user.id, filters); const chartName = 'summary-chart.png'; const chart = await buildChartAttachment(points, chartName, { title: 'Betting Summary', subtitle: 'Record-first view with cumulative profit context', }); await interaction.reply({ embeds: [buildSummaryEmbed(summary, chartName, filters)], files: [chart], }); } async function handleBooks(interaction, store) { const filters = getAnalyticsFilters(interaction); const breakdown = await store.getBookBreakdown(interaction.user.id, filters); await interaction.reply({ embeds: [buildBooksEmbed(breakdown, filters)], }); } async function handleSports(interaction, store) { const filters = getAnalyticsFilters(interaction); const breakdown = await store.getSportBreakdown(interaction.user.id, filters); await interaction.reply({ embeds: [buildSportsEmbed(breakdown, filters)], }); } async function handleExport(interaction, store) { const filters = getAnalyticsFilters(interaction); const rows = await store.exportBets(interaction.user.id, filters); const attachment = buildExportAttachment(rows); await interaction.reply({ embeds: [ buildErrorEmbed( 'CSV Export Ready', `Exported **${rows.length}** bet${rows.length === 1 ? '' : 's'} with your current filters.` ).setColor(BRAND_COLORS.primary), ], files: [attachment], flags: MessageFlags.Ephemeral, }); } async function handleWelcome(interaction, config) { const isAdmin = await memberHasRoleName(interaction, config.adminRoleName); if (!isAdmin) { await interaction.reply({ embeds: [ buildErrorEmbed( 'Not allowed', `Only members with the **${config.adminRoleName}** role can use this command.` ), ], flags: MessageFlags.Ephemeral, }); return; } await interaction.reply({ embeds: [buildWelcomeEmbed()], }); await interaction.followUp({ content: '@everyone', allowedMentions: { parse: ['everyone'] }, }); } async function handleAlerts(interaction) { if (interaction.user.username !== ALERTS_ALLOWED_USERNAME) { await interaction.reply({ embeds: [ buildErrorEmbed( 'Not allowed', `Only **${ALERTS_ALLOWED_USERNAME}** can use this command.` ), ], flags: MessageFlags.Ephemeral, }); return; } const channel = await interaction.client.channels.fetch(ALERTS_CHANNEL_ID); if (!channel || !channel.isTextBased() || channel.type === ChannelType.DM) { await interaction.reply({ embeds: [ buildErrorEmbed( 'Welcome channel unavailable', `I could not post to channel **${ALERTS_CHANNEL_ID}**.` ), ], flags: MessageFlags.Ephemeral, }); return; } await channel.send({ embeds: [buildAlertsEmbed()], components: buildAlertsButtonRows(), }); await channel.send({ content: '@everyone', allowedMentions: { parse: ['everyone'] }, }); await interaction.reply({ embeds: [ buildErrorEmbed('Alerts panel posted', `Sent the analyst alert panel and @everyone ping to <#${ALERTS_CHANNEL_ID}>.`).setColor(BRAND_COLORS.primary), ], flags: MessageFlags.Ephemeral, }); } async function handleBulkDeleteMessages(interaction) { if (interaction.user.username !== ALERTS_ALLOWED_USERNAME) { await interaction.reply({ embeds: [ buildErrorEmbed( 'Not allowed', `Only **${ALERTS_ALLOWED_USERNAME}** can use this command.` ), ], flags: MessageFlags.Ephemeral, }); return; } if (!interaction.inGuild() || !interaction.channel || interaction.channel.type === ChannelType.DM || typeof interaction.channel.bulkDelete !== 'function') { await interaction.reply({ embeds: [ buildErrorEmbed( 'Channel unavailable', 'This command can only bulk delete messages in a server text channel.' ), ], flags: MessageFlags.Ephemeral, }); return; } const requestedCount = interaction.options.getInteger('count', true); await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const now = Date.now(); const bulkDeleteCutoffMs = 14 * 24 * 60 * 60 * 1000; let remainingToInspect = requestedCount; let beforeMessageId; let deletedCount = 0; let skippedCount = 0; try { while (remainingToInspect > 0) { const fetchLimit = Math.min(100, remainingToInspect); const batch = await interaction.channel.messages.fetch({ limit: fetchLimit, ...(beforeMessageId ? { before: beforeMessageId } : {}), }); if (batch.size === 0) { break; } remainingToInspect -= batch.size; beforeMessageId = batch.last()?.id; const deletableMessages = batch.filter((message) => !message.pinned && !message.system && (now - message.createdTimestamp) < bulkDeleteCutoffMs ); skippedCount += batch.size - deletableMessages.size; if (deletableMessages.size === 0) { continue; } const deleted = await interaction.channel.bulkDelete(deletableMessages, true); deletedCount += deleted.size; } await finalizeDeferredInteraction(interaction, { embeds: [ buildErrorEmbed( 'Bulk delete complete', `Deleted **${deletedCount}** recent message${deletedCount === 1 ? '' : 's'} from this channel.${skippedCount > 0 ? ` Skipped **${skippedCount}** pinned, system, or older-than-14-days message${skippedCount === 1 ? '' : 's'}.` : ''}` ).setColor(BRAND_COLORS.primary), ], flags: MessageFlags.Ephemeral, }); } catch (error) { if (error instanceof DiscordAPIError && error.code === 50013) { await finalizeDeferredInteraction(interaction, { embeds: [ buildErrorEmbed( 'Missing permissions', 'The bot needs permission to manage messages in this channel before it can bulk delete them.' ), ], flags: MessageFlags.Ephemeral, }); return; } throw error; } } async function handleScanStatus(interaction, config) { const isAdmin = await memberHasRoleName(interaction, config.adminRoleName); if (!isAdmin) { await denyAdminOnly(interaction, config.adminRoleName); return; } const scanner = interaction.client.__marketScanner; await interaction.reply({ embeds: [buildScanStatusEmbed(scanner?.getStatus?.() ?? { enabled: false, frequencyMinutes: 0, morningTime: 'N/A', timeZone: 'N/A', minBooks: 0, disagreementThreshold: 0 })], flags: MessageFlags.Ephemeral, }); } async function handleScanRun(interaction, config) { const isAdmin = await memberHasRoleName(interaction, config.adminRoleName); if (!isAdmin) { await denyAdminOnly(interaction, config.adminRoleName); return; } const scanner = interaction.client.__marketScanner; if (!scanner?.getStatus().enabled) { await interaction.reply({ embeds: [buildErrorEmbed('Scanner disabled', 'Set the market scan environment variables before running scanner commands.')], flags: MessageFlags.Ephemeral, }); return; } await interaction.deferReply({ flags: MessageFlags.Ephemeral }); try { const analysis = await scanner.runDisagreementScan(); await interaction.editReply({ embeds: [ buildMarketTopEmbed('Manual Scan Complete', analysis.circaAlerts.slice(0, 10), { metricLabel: 'Circa Deviation', metricFormatter: (row) => `${(row.maxDeviation * 100).toFixed(2)}%`, secondaryValue: (row) => `${row.furthestBookName} @ ${row.furthestBookOddsInput}`, }), ], }); } catch (error) { await interaction.editReply({ embeds: [buildErrorEmbed('Manual scan failed', error.message || 'The scanner hit an unexpected error.')], }); } } async function handleOddsQuota(interaction, config) { const isAdmin = await memberHasRoleName(interaction, config.adminRoleName); if (!isAdmin) { await denyAdminOnly(interaction, config.adminRoleName); return; } const scanner = interaction.client.__marketScanner; if (!scanner?.getStatus().oddsWorkflowEnabled) { await interaction.reply({ embeds: [buildErrorEmbed('Odds lookup disabled', 'Set the Odds API environment variables before using this command.')], flags: MessageFlags.Ephemeral, }); return; } await interaction.deferReply({ flags: MessageFlags.Ephemeral }); try { const quota = await scanner.refreshOddsApiQuota(); await interaction.editReply({ embeds: [buildOddsApiQuotaEmbed(quota)], }); } catch (error) { await interaction.editReply({ embeds: [buildErrorEmbed('Quota lookup failed', error.message || 'The Odds API quota lookup hit an unexpected error.')], }); } } async function handleScanReport(interaction, config) { const isAdmin = await memberHasRoleName(interaction, config.adminRoleName); if (!isAdmin) { await denyAdminOnly(interaction, config.adminRoleName); return; } const scanner = interaction.client.__marketScanner; if (!scanner?.getStatus().enabled) { await interaction.reply({ embeds: [buildErrorEmbed('Scanner disabled', 'Set the market scan environment variables before running scanner commands.')], flags: MessageFlags.Ephemeral, }); return; } await interaction.deferReply({ flags: MessageFlags.Ephemeral }); try { await scanner.runMorningReport(); await interaction.editReply({ embeds: [buildErrorEmbed('Morning report posted', `Sent the discrepancy and width reports to <#${config.scanner.scanReportChannelId}>.`).setColor(BRAND_COLORS.primary)], }); } catch (error) { await interaction.editReply({ embeds: [buildErrorEmbed('Morning report failed', error.message || 'The scanner hit an unexpected error.')], }); } } async function handleCircaTest(interaction, config) { const isAdmin = await memberHasRoleName(interaction, config.adminRoleName); if (!isAdmin) { await denyAdminOnly(interaction, config.adminRoleName); return; } const scanner = interaction.client.__marketScanner; if (!scanner?.getStatus().enabled) { await interaction.reply({ embeds: [buildErrorEmbed('Scanner disabled', 'Set the market scan environment variables before running scanner commands.')], flags: MessageFlags.Ephemeral, }); return; } await interaction.deferReply({ flags: MessageFlags.Ephemeral }); try { const diagnostic = await scanner.runCircaDiagnostic(); await interaction.editReply({ embeds: [buildCircaDiagnosticEmbed(diagnostic)], }); } catch (error) { await interaction.editReply({ embeds: [buildErrorEmbed('Circa test failed', error.message || 'The Circa diagnostic hit an unexpected error.')], }); } } async function handleCircaMarket(interaction, config) { const marketType = interaction.options.getString('market', true); await handleCircaShortcut(interaction, config, marketType); } async function handleCircaShortcut(interaction, config, marketType) { await interaction.deferReply(); const scanner = interaction.client.__marketScanner; if (!scanner?.getStatus().circaWorkflowEnabled) { await interaction.editReply({ embeds: [buildErrorEmbed('Circa disabled', 'Set the Circa environment variables before using Circa commands.')], }); return; } try { const market = await scanner.getLatestCircaMarket(marketType); const embeds = buildCircaMarketEmbeds(market.snapshot, market.marketLabel, market.entries, { footerText: `Manual Circa view requested by ${interaction.user.displayName ?? interaction.user.username}`, }); const paginationRow = buildCircaPaginationRow({ userId: interaction.user.id, marketType, page: 1, totalPages: embeds.length, }); await interaction.editReply({ embeds: [embeds[0]], components: paginationRow ? [paginationRow] : [], }); } catch (error) { await interaction.editReply({ embeds: [buildErrorEmbed('Circa market unavailable', error.message || 'The Circa market lookup hit an unexpected error.')], }); } } async function handlePlayerOdds(interaction, config, marketType, lane = 'sportsbook') { await interaction.deferReply(); const scanner = interaction.client.__marketScanner; if (!scanner?.getStatus().oddsWorkflowEnabled) { await interaction.editReply({ embeds: [buildErrorEmbed('Odds lookup disabled', 'Set the Odds API environment variables before using this command.')], }); return; } try { const player = interaction.options.getString('player', true); const book = interaction.options.getString('book') ?? null; const result = await scanner.getPlayerMarketOdds(marketType, player, { book, lane }); await interaction.editReply({ embeds: [buildPlayerOddsEmbed(result)], }); } catch (error) { await interaction.editReply({ embeds: [buildErrorEmbed('Odds unavailable', error.message || 'The player odds lookup hit an unexpected error.')], }); } } async function handleSharpBoard(interaction, config, commandName, lane = 'sportsbook') { await interaction.deferReply(); const scanner = interaction.client.__marketScanner; if (!scanner?.getStatus().oddsWorkflowEnabled) { await interaction.editReply({ embeds: [buildErrorEmbed('Sharp lookup disabled', 'Set the Odds API environment variables before using these market-awareness commands.')], }); return; } const filters = { ...getMarketIntelligenceFilters(interaction), lane }; try { if (commandName === 'edgeboard') { const result = await scanner.getEdgeBoard(filters); await interaction.editReply({ embeds: [buildSharpEdgeBoardEmbed('Edge Board', result.rows, filters)] }); return; } if (commandName === 'sharpboard') { const result = await scanner.getSharpBoard(filters); await interaction.editReply({ embeds: [buildSharpEdgeBoardEmbed('Sharp Board', result.rows, filters)] }); return; } if (commandName === 'marketedge') { const market = interaction.options.getString('market', true); const result = await scanner.getMarketEdge(market, filters); await interaction.editReply({ embeds: [buildSharpEdgeBoardEmbed('Market Edge', result.rows, filters)] }); return; } if (commandName === 'widthboard') { const result = await scanner.getWidthBoard(filters); await interaction.editReply({ embeds: [buildSharpEdgeBoardEmbed('Width Board', result.rows, filters)] }); return; } if (commandName === 'playeredge') { const player = interaction.options.getString('player', true); const result = await scanner.getPlayerEdge(player, filters); await interaction.editReply({ embeds: [buildPlayerEdgeEmbed({ playerName: player, rows: result.rows }, filters)] }); return; } if (commandName === 'consensusvs') { const book = interaction.options.getString('book', true); const result = await scanner.getConsensusVsBook(book, filters); await interaction.editReply({ embeds: [buildConsensusVsEmbed(result, filters)] }); return; } if (commandName === 'bookscoreboard') { const result = await scanner.getBookScoreboard(filters); await interaction.editReply({ embeds: [buildBookScoreboardEmbed('Book Scoreboard', result.rows, filters)] }); return; } if (commandName === 'markethealth') { const result = await scanner.getMarketHealth(filters); await interaction.editReply({ embeds: [buildMarketHealthEmbed('Market Health', result.rows, filters)] }); return; } if (commandName === 'steam') { const result = await scanner.getSteamBoard(filters); await interaction.editReply({ embeds: [buildSteamEmbed('Steam', result.rows, filters)] }); return; } await interaction.editReply({ embeds: [buildErrorEmbed('Command unavailable', 'That sharp market command is not wired up yet.')], }); } catch (error) { await interaction.editReply({ embeds: [buildErrorEmbed('Sharp view unavailable', error.message || 'The sharp market lookup hit an unexpected error.')], }); } } async function handleMatchupCommand(interaction, config, commandName) { if (!interaction.deferred && !interaction.replied) { await interaction.deferReply(); } const matchupService = interaction.client.__matchupService; if (!config.matchups.enabled || !matchupService) { await interaction.editReply({ embeds: [buildErrorEmbed('Matchups unavailable', 'Configure MLB_HOSTED_BASE_URL or Cockroach access before using matchup commands.')], }); return; } const filters = getMatchupFilters(interaction); try { if (commandName === 'matchuphitters') { const result = await matchupService.getTopHitters(filters); await interaction.editReply({ embeds: [buildMatchupHittersEmbed(result, filters)] }); return; } if (commandName === 'matchuppitchers') { const result = await matchupService.getTopPitchers(filters); await interaction.editReply({ embeds: [buildMatchupPitchersEmbed(result, filters)] }); return; } if (commandName === 'bestmatchups') { const result = await matchupService.getBestMatchups(filters); await interaction.editReply({ embeds: [buildBestMatchupsEmbed(result, filters)] }); return; } if (commandName === 'teambestmatchups') { const result = await matchupService.getTeamBestMatchups(filters); await interaction.editReply({ embeds: [buildBestMatchupsEmbed(result, filters, { title: 'Team Best Matchups' })] }); return; } if (commandName === 'playercontext') { const result = await matchupService.getPlayerContext(filters); await interaction.editReply({ embeds: [buildPlayerContextEmbed(result, filters)] }); return; } await interaction.editReply({ embeds: [buildErrorEmbed('Command unavailable', 'That matchup command is not wired up yet.')], }); } catch (error) { await interaction.editReply({ embeds: [buildErrorEmbed('Matchup data unavailable', error.message || 'The matchup lookup hit an unexpected error.')], }); } } async function handleBaseballChartCommand(interaction, config, commandName) { if (!interaction.__ackFailed && !interaction.deferred && !interaction.replied) { await interaction.deferReply(); } const matchupService = interaction.client.__matchupService; if (!config.matchups.enabled || !matchupService) { await interaction.editReply({ embeds: [buildErrorEmbed('Charts unavailable', 'Configure matchup data before using baseball chart commands.')], }); return; } const filters = getBaseballChartFilters(interaction); try { if (commandName === 'hrboardchart') { const result = await matchupService.getHrBoardChartData(filters); const png = await createHrBoardChartPng({ title: 'HR Board Chart', subtitle: `Top HR targets from ${String(result.source ?? 'unknown').toUpperCase()} for ${result.resolvedDate ?? 'unknown slate'}.`, rows: result.rows, }); const fileName = 'hr-board-chart.png'; await finalizeDeferredInteraction(interaction, { embeds: [buildHrBoardChartEmbed(result, filters, fileName)], files: [buildBaseballChartAttachment(png, fileName)], }); return; } if (commandName === 'hrtrend') { const result = await matchupService.getHrTrendData(filters); const png = await createHrTrendChartPng({ title: `HR Trend - ${result.playerName}`, subtitle: `${result.team ?? 'N/A'} | ${result.resolvedDate ?? 'Unknown slate'}`, points: result.points, primaryLabel: result.primaryLabel, overlays: result.overlays, }); const fileName = 'hr-trend-chart.png'; await finalizeDeferredInteraction(interaction, { embeds: [buildHrTrendEmbed(result, fileName)], files: [buildBaseballChartAttachment(png, fileName)], }); return; } if (commandName === 'hrprofile') { const result = await matchupService.getHrProfileData(filters); const png = await createHrProfileRadarPng({ title: `HR Profile - ${result.playerName}`, subtitle: `${result.team ?? 'N/A'} | ${result.resolvedDate ?? 'Unknown slate'}`, labels: result.labels, values: result.values, seriesLabel: 'HR Profile', }); const fileName = 'hr-profile-radar.png'; await finalizeDeferredInteraction(interaction, { embeds: [buildHrProfileEmbed(result, fileName)], files: [buildBaseballChartAttachment(png, fileName)], }); return; } if (commandName === 'hrvaluechart') { const result = await matchupService.getHrValueChartData(filters); const png = await createHrValueScatterPng({ title: 'HR Price vs Model', subtitle: `${result.resolvedDate ?? 'Unknown slate'}${filters.book ? ` | ${filters.book}` : ''}`, points: result.points, }); const fileName = 'hr-value-chart.png'; await finalizeDeferredInteraction(interaction, { embeds: [buildHrValueChartEmbed(result, filters, fileName)], files: [buildBaseballChartAttachment(png, fileName)], }); return; } if (commandName === 'hrzone') { const result = await matchupService.getHrZoneData(filters); const png = await createHrZoneOverlayCardPng({ title: 'HR Zone Overlay', subtitle: `${result.resolvedDate ?? 'Unknown slate'} | ${result.team ?? 'N/A'}`, playerName: result.playerName, team: result.team, opponentPitcher: result.opposingPitcherName, pitcherHand: result.opposingPitcherHand, zoneFitScore: result.zone_fit_score, cells: result.cells, bestOverlay: result.bestOverlay, shapeSummary: result.shapeSummary, read: result.read, }); const fileName = 'hr-zone-card.png'; await finalizeDeferredInteraction(interaction, { embeds: [buildHrZoneEmbed(result, fileName)], files: [buildBaseballChartAttachment(png, fileName)], }); return; } if (commandName === 'ktrend') { const result = await matchupService.getKTrendData(filters); const png = await createKTrendChartPng({ title: `K Trend - ${result.pitcherName}`, subtitle: `${result.team ?? 'N/A'} | ${result.resolvedDate ?? 'Unknown slate'}`, points: result.points, primaryLabel: result.primaryLabel, overlays: result.overlays, }); const fileName = 'k-trend-chart.png'; await finalizeDeferredInteraction(interaction, { embeds: [buildKTrendEmbed(result, fileName)], files: [buildBaseballChartAttachment(png, fileName)], }); return; } if (commandName === 'kladder') { const result = await matchupService.getKLadderData(filters); const png = await createKLadderChartPng({ title: `K Ladder - ${result.pitcherName}`, subtitle: `${result.book ?? 'Best available book'} | ${result.resolvedDate ?? 'Unknown slate'}`, rows: result.rows, }); const fileName = 'k-ladder-chart.png'; await finalizeDeferredInteraction(interaction, { embeds: [buildKLadderEmbed(result, fileName)], files: [buildBaseballChartAttachment(png, fileName)], }); return; } if (commandName === 'kprofile') { const result = await matchupService.getKProfileData(filters); const png = await createKProfileRadarPng({ title: `K Profile - ${result.pitcherName}`, subtitle: `${result.team ?? 'N/A'} | ${result.resolvedDate ?? 'Unknown slate'}`, labels: result.labels, values: result.values, seriesLabel: 'Strikeout Profile', }); const fileName = 'k-profile-radar.png'; await finalizeDeferredInteraction(interaction, { embeds: [buildKProfileEmbed(result, fileName)], files: [buildBaseballChartAttachment(png, fileName)], }); return; } if (commandName === 'kmatchup') { const result = await matchupService.getKMatchupData(filters); const png = await createKMatchupCardPng({ title: 'Pitcher K Matchup', subtitle: `${result.resolvedDate ?? 'Unknown slate'} | ${result.team ?? 'N/A'}`, pitcherName: result.pitcherName, opponentLabel: result.opponentTeam ?? 'Opponent Context', strikeoutScore: result.overview?.strikeout_score, pitcherMetrics: result.pitcherMetrics, opponentMetrics: result.opponentMetrics, read: result.read, }); const fileName = 'k-matchup-card.png'; await finalizeDeferredInteraction(interaction, { embeds: [buildKMatchupEmbed(result, fileName)], files: [buildBaseballChartAttachment(png, fileName)], }); return; } if (commandName === 'kcount') { const result = await matchupService.getKCountData(filters); const png = await createKCountLeverageChartPng({ title: `Count Leverage - ${result.pitcherName}`, subtitle: `${result.resolvedDate ?? 'Unknown slate'} | ${result.team ?? 'N/A'}`, labels: result.labels, datasets: result.datasets, }); const fileName = 'k-count-chart.png'; await finalizeDeferredInteraction(interaction, { embeds: [buildKCountEmbed(result, fileName)], files: [buildBaseballChartAttachment(png, fileName)], }); return; } if (commandName === 'pitchertrend') { const result = await matchupService.getPitcherTrendChartData(filters); const png = await createPitcherTrendChartPng({ chartType: result.chartType, title: result.title, subtitle: result.subtitle, points: result.points, primaryLabel: result.primaryLabel, overlays: result.overlays, labels: result.labels, values: result.values, datasets: result.datasets, }); const fileName = 'pitcher-trend-chart.png'; await finalizeDeferredInteraction(interaction, { embeds: [buildPitcherTrendEmbed(result, fileName)], files: [buildBaseballChartAttachment(png, fileName)], }); return; } if (commandName === 'pitcherarsenal') { const result = await matchupService.getPitcherArsenalChartData(filters); const png = await createPitcherArsenalChartPng({ title: result.title, subtitle: result.subtitle, pitcherName: result.pitcherName, teamLine: `${result.team ?? 'N/A'}${result.opponentTeam ? ` vs ${result.opponentTeam}` : ''}`, columns: result.columns, rows: result.rows, read: result.read, }); const fileName = 'pitcher-arsenal-chart.png'; await finalizeDeferredInteraction(interaction, { embeds: [buildPitcherArsenalEmbed(result, fileName)], files: [buildBaseballChartAttachment(png, fileName)], }); return; } if (commandName === 'pitcherlocation') { const result = await matchupService.getPitcherLocationChartData(filters); const png = await createPitcherLocationChartPng({ view: result.view, title: result.title, subtitle: result.subtitle, pitcherName: result.pitcherName, teamLine: `${result.team ?? 'N/A'}${result.opponentTeam ? ` vs ${result.opponentTeam}` : ''}`, metricLabel: result.view, cells: result.cells, plotPoints: result.plotPoints, pitchBreakdown: result.pitchBreakdown, sampleSize: result.sampleSize, metricConfig: result.metricConfig, bestOverlay: result.bestOverlay, shapeSummary: result.shapeSummary, read: result.read, }); const fileName = `pitcher-location-${result.view ?? 'chart'}-${Date.now()}.png`; await finalizeDeferredInteraction(interaction, { embeds: [buildPitcherLocationEmbed(result, fileName)], files: [buildBaseballChartAttachment(png, fileName)], }); return; } if (commandName === 'pitcherapproach') { const result = await matchupService.getPitcherApproachChartData(filters); const png = await createPitcherApproachChartPng({ title: result.title, subtitle: result.subtitle, labels: result.labels, datasets: result.datasets, sampleSize: result.sampleSize, }); const fileName = `pitcher-approach-${result.view ?? 'chart'}-${Date.now()}.png`; await finalizeDeferredInteraction(interaction, { embeds: [buildPitcherApproachEmbed(result, fileName)], files: [buildBaseballChartAttachment(png, fileName)], }); return; } if (commandName === 'pitchercompare') { const result = await matchupService.getPitcherCompareChartData(filters); const png = await createPitcherCompareChartPng({ chartType: result.chartType, title: result.title, subtitle: result.subtitle, pitcherName: result.pitcherName, teamLine: `${result.team ?? 'N/A'}${result.opponentTeam ? ` vs ${result.opponentTeam}` : ''}`, points: result.points, rows: result.rows, compareLabel: result.compareLabel, baselineLabel: result.baselineLabel, read: result.read, }); const fileName = 'pitcher-compare-chart.png'; await finalizeDeferredInteraction(interaction, { embeds: [buildPitcherCompareEmbed(result, fileName)], files: [buildBaseballChartAttachment(png, fileName)], }); return; } await finalizeDeferredInteraction(interaction, { embeds: [buildErrorEmbed('Command unavailable', 'That baseball chart command is not wired up yet.')], }); } catch (error) { console.error('Baseball chart command failed', { command: commandName, userId: interaction.user?.id, guildId: interaction.guildId, error: serializeError(error), }); await finalizeDeferredInteraction(interaction, { embeds: [buildErrorEmbed('Chart unavailable', error.message || 'The baseball chart lookup hit an unexpected error.')], }); } } async function finalizeDeferredInteraction(interaction, payload) { if (interaction.__ackFailed) { const channelPayload = { ...payload, content: payload.content ?? `<@${interaction.user?.id}>`, flags: undefined, }; await interaction.channel?.send?.(channelPayload).catch(() => null); return; } try { await interaction.editReply(payload); } catch (error) { if (error?.code === 10008 || error?.code === 10062) { await interaction.followUp(payload).catch(() => null); return; } throw error; } } function shouldDeferImmediately(commandName) { return [ 'matchuphitters', 'matchuppitchers', 'playercontext', 'bestmatchups', 'teambestmatchups', 'hrboardchart', 'hrtrend', 'hrprofile', 'hrvaluechart', 'hrzone', 'ktrend', 'kladder', 'kprofile', 'kmatchup', 'kcount', 'pitchertrend', 'pitcherarsenal', 'pitcherlocation', 'pitcherapproach', 'pitchercompare', ].includes(commandName); } async function handleMatchupHealth(interaction, config) { const isAdmin = await memberHasRoleName(interaction, config.adminRoleName); if (!isAdmin) { await denyAdminOnly(interaction, config.adminRoleName); return; } const matchupService = interaction.client.__matchupService; if (!config.matchups.enabled || !matchupService) { await interaction.reply({ embeds: [buildErrorEmbed('Matchups unavailable', 'Configure MLB_HOSTED_BASE_URL or Cockroach access before using matchup commands.')], flags: MessageFlags.Ephemeral, }); return; } await interaction.deferReply({ flags: MessageFlags.Ephemeral }); try { const result = await matchupService.getHealth(getMatchupFilters(interaction)); await interaction.editReply({ embeds: [buildMatchupHealthEmbed(result)], }); } catch (error) { await interaction.editReply({ embeds: [buildErrorEmbed('Matchup health unavailable', error.message || 'The matchup health lookup hit an unexpected error.')], }); } } async function handleButton(interaction, store) { const alertRoleName = parseAlertRoleButtonId(interaction.customId); if (alertRoleName) { await handleAlertRoleToggle(interaction, alertRoleName); return; } const pageState = parseBetsPageId(interaction.customId); if (pageState) { if (pageState.userId !== interaction.user.id) { await interaction.reply({ embeds: [buildErrorEmbed('Not your pager', 'Only the original user can turn pages on this bet ledger view.')], flags: MessageFlags.Ephemeral, }); return; } interaction.__betsFilters = { dateWindow: pageState.dateWindow, book: pageState.book, sport: pageState.sport, status: pageState.status, }; await handleBets(interaction, store, pageState.page, true); return; } const circaPageState = parseCircaPageId(interaction.customId); if (!circaPageState) { return; } if (circaPageState.userId !== interaction.user.id) { await interaction.reply({ embeds: [buildErrorEmbed('Not your pager', 'Only the original user can turn pages on this Circa market view.')], flags: MessageFlags.Ephemeral, }); return; } const scanner = interaction.client.__marketScanner; try { const market = await scanner.getLatestCircaMarket(circaPageState.marketType); const embeds = buildCircaMarketEmbeds(market.snapshot, market.marketLabel, market.entries, { footerText: `Manual Circa view requested by ${interaction.user.displayName ?? interaction.user.username}`, }); const safePage = Math.max(1, Math.min(circaPageState.page, embeds.length)); const paginationRow = buildCircaPaginationRow({ userId: interaction.user.id, marketType: circaPageState.marketType, page: safePage, totalPages: embeds.length, }); await interaction.update({ embeds: [embeds[safePage - 1]], components: paginationRow ? [paginationRow] : [], }); } catch (error) { await interaction.reply({ embeds: [buildErrorEmbed('Circa market unavailable', error.message || 'The Circa market pager hit an unexpected error.')], flags: MessageFlags.Ephemeral, }); } } async function handleAlertRoleToggle(interaction, roleName) { if (!interaction.guild) { await interaction.reply({ embeds: [buildErrorEmbed('Server only', 'Alert role buttons can only be used inside the server.')], flags: MessageFlags.Ephemeral, }); return; } if (!ALERT_ROLE_NAMES.includes(roleName)) { await interaction.reply({ embeds: [buildErrorEmbed('Unknown alert role', 'That alert role is not configured on this bot.')], flags: MessageFlags.Ephemeral, }); return; } const member = await interaction.guild.members.fetch(interaction.user.id); const role = interaction.guild.roles.cache.find((item) => item.name === roleName) ?? await interaction.guild.roles.fetch().then((roles) => roles.find((item) => item.name === roleName)); if (!role) { await interaction.reply({ embeds: [buildErrorEmbed('Role not found', `I could not find the **${roleName}** role in this server.`)], flags: MessageFlags.Ephemeral, }); return; } const hasRole = member.roles.cache.has(role.id); if (hasRole) { await member.roles.remove(role.id); } else { await member.roles.add(role.id); } await interaction.reply({ embeds: [ buildErrorEmbed( hasRole ? 'Alert removed' : 'Alert added', `${hasRole ? 'Removed' : 'Added'} **${roleName}** for <@${interaction.user.id}>.` ).setColor(hasRole ? BRAND_COLORS.muted : BRAND_COLORS.success), ], flags: MessageFlags.Ephemeral, }); } async function memberHasRoleName(interaction, roleName) { if (!interaction.guild) { return false; } const member = await interaction.guild.members.fetch(interaction.user.id); return member.roles.cache.some((role) => role.name === roleName); } async function denyAdminOnly(interaction, roleName) { await interaction.reply({ embeds: [buildErrorEmbed('Not allowed', `Only members with the **${roleName}** role can use this command.`)], flags: MessageFlags.Ephemeral, }); } function auditLog(event, payload) { console.log(`[audit] ${event}`, payload); } function serializeError(error) { return { message: error?.message, stack: error?.stack, code: error?.code, }; } main().catch((error) => { console.error(error); process.exit(1); });