ROIBot / src /index.js
Codex
Wire pitcher location payloads through
6e99a7b
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);
});