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