diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..3e22f4b --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/hellcom.iml b/.idea/hellcom.iml new file mode 100644 index 0000000..24643cc --- /dev/null +++ b/.idea/hellcom.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..ffeb3e3 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml new file mode 100644 index 0000000..0c83ac4 --- /dev/null +++ b/.idea/prettier.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/api-wrapper/api.ts b/src/api-wrapper/api.ts index db10e89..d566c20 100644 --- a/src/api-wrapper/api.ts +++ b/src/api-wrapper/api.ts @@ -15,8 +15,10 @@ import {writeFileSync} from 'fs'; import {getAllPlanets} from './planets'; import axios, {AxiosRequestConfig} from 'axios'; import {config} from '../config'; +import {logger} from '../handlers'; const API_URL = 'https://api.live.prod.thehelldiversgame.com/api'; +const CHATS_URL = 'https://api.diveharder.com/v1/all'; const {IDENTIFIER} = config; export const seasons = { @@ -92,10 +94,10 @@ export let data: ApiData = { const axiosOpts: AxiosRequestConfig = { headers: { 'Accept-Language': 'en-us', + 'User-Agent': 'HellComBot/1.0', }, }; -let getDataCounter = 0; export async function getData() { const season = seasons.current; // https://api.live.prod.thehelldiversgame.com/api/WarSeason/801/Status @@ -125,6 +127,19 @@ export async function getData() { ).data; const planetStats = statsApi as PlanetStats; + let chatsAPI; + try { + // Unofficial: api wrapper for the authed chats endpoint + chatsAPI = await ( + await axios.get(CHATS_URL, {...axiosOpts, timeout: 10_000}) + ).data; + } catch (err) { + logger.error('Failed to fetch chats data.', { + type: 'API', + ...(err as Error), + }); + } + // let planetStats: PlanetStats = data.PlanetStats; // if (getDataCounter % 2 === 0) { // const planetStatsApi = await ( @@ -250,8 +265,13 @@ export async function getData() { // this is the starting point in unix for whatever time thing they use UTCOffset: Math.floor(status.timeUtc - status.time * 1000), // use this value to add to the time to get the UTC time in seconds }; + if ( + chatsAPI && + chatsAPI['store_rotation'] && + chatsAPI['store_rotation'].items + ) + data.SuperStore = chatsAPI['store_rotation']; - getDataCounter++; writeFileSync('data.json', JSON.stringify(data, null, 2)); return data; } diff --git a/src/api-wrapper/types.ts b/src/api-wrapper/types.ts index d2d6c05..26e034a 100644 --- a/src/api-wrapper/types.ts +++ b/src/api-wrapper/types.ts @@ -241,6 +241,25 @@ export type NewsFeedItem = { message: string; }; +export type StoreItem = { + name: string; + description: string; + type: 'Light' | 'Medium' | 'Heavy'; + slot: 'Head' | 'Body' | 'Cloak'; + armor_rating: number; + speed: number; + stamina_regen: number; + passive: { + name: string; + description: string; + }; +}; + +export type StoreRotation = { + expire_time: Date; + items: StoreItem[]; +}; + export type SteamNewsItem = { title: string; url: string; @@ -309,6 +328,7 @@ export type ApiData = { ActivePlanets: MergedPlanetData[]; PlanetAttacks: {source: string; target: string}[]; Events: GlobalEvent[]; + SuperStore?: StoreRotation; Players: { [key in Faction]: number; }; diff --git a/src/commands/index.ts b/src/commands/index.ts index 53f3d19..218af5c 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -12,8 +12,10 @@ import dispatches from './dispatches'; import events from './events'; import history from './history'; import map from './map'; +import steam from './steam'; import planet from './planet'; import subscribe from './subscribe'; +import superstore from './superstore'; import wiki from './wiki'; import {Category, WikiData} from '../handlers'; @@ -27,7 +29,9 @@ const commandList: Command[] = [ history, community, map, - wiki, + // steam, + // wiki, + superstore, ]; const notEphemeral: string[] = []; const ephemeralCmds = commandList diff --git a/src/commands/steam.ts b/src/commands/steam.ts new file mode 100644 index 0000000..4c68c63 --- /dev/null +++ b/src/commands/steam.ts @@ -0,0 +1,151 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + CommandInteraction, + EmbedBuilder, + SlashCommandBuilder, +} from 'discord.js'; +import {Command} from '../interfaces'; +import {FOOTER_MESSAGE} from './_components'; +import axios from 'axios'; +import {SteamNewsFeed} from '../api-wrapper'; +import dayjs from 'dayjs'; + +const command: Command = { + data: new SlashCommandBuilder() + .setName('steam') + .setDescription('View the latest Helldivers 2 / bot patch notes') + .addSubcommand(subcommand => + subcommand + .setName('news') + .setDescription('View the latest Helldivers 2 non-patch news') + ) + .addSubcommand(subcommand => + subcommand + .setName('patchnotes') + .setDescription('View the latest Helldivers 2 patch notes') + ), + run: async interaction => { + const subcommand = interaction.options.data[0].name; + + await subcmds[subcommand](interaction); + }, +}; + +const subcmds: {[key: string]: (job: CommandInteraction) => Promise} = { + patchnotes, + news, +}; + +async function patchnotes(interaction: CommandInteraction) { + const axiosOpts = { + headers: { + 'User-Agent': 'HelldiversBot/1.0', + 'Accept-Language': 'en-US', + }, + }; + const apiData = (await ( + await axios.get('http://api.diveharder.com/raw/updates', { + ...axiosOpts, + params: { + maxEntries: 512, + }, + }) + ).data) as SteamNewsFeed; + const steamPatchNotes = apiData.filter(news => + news.title.toLowerCase().includes('patch') + ); + + const messages = steamPatchNotes + .slice(0, 3) + .reverse() + .map(p => { + const embed = new EmbedBuilder() + .setTitle(p.title) + .setURL(p.url) + .setTimestamp(dayjs(p.date).unix() * 1000) + .setFooter({text: FOOTER_MESSAGE}); + + if (p.contents.length > 4000) + embed.setDescription( + p.contents.slice(0, 4000) + + '`...`\n\n## Click the button below to read more!' + ); + else embed.setDescription(p.contents); + + return { + embeds: [embed], + components: [ + new ActionRowBuilder().addComponents([ + new ButtonBuilder() + .setLabel('Steam Post') + .setStyle(ButtonStyle.Link) + .setURL(p.url), + ]), + ], + }; + }); + + messages.forEach(async (m, i) => { + if (i === 0) await interaction.editReply({...m}); + else await interaction.followUp({...m, ephemeral: true}); + }); +} + +async function news(interaction: CommandInteraction) { + const axiosOpts = { + headers: { + 'User-Agent': 'HelldiversBot/1.0', + 'Accept-Language': 'en-US', + }, + }; + const apiData = (await ( + await axios.get('https://api.diveharder.com/raw/updates', { + ...axiosOpts, + params: { + maxEntries: 512, + }, + }) + ).data) as SteamNewsFeed; + const steamNews = apiData.filter( + news => !news.title.toLowerCase().includes('patch') + ); + + const messages = steamNews + .slice(0, 3) + .reverse() + .map(p => { + const embed = new EmbedBuilder() + .setTitle(p.title) + .setURL(p.url) + .setTimestamp(dayjs(p.date).unix() * 1000) + .setFooter({text: FOOTER_MESSAGE}); + + if (p.contents.length > 4000) + embed.setDescription( + p.contents.slice(0, 4000) + + '`...`\n\n## Click the button below to read more!' + ); + else embed.setDescription(p.contents); + + return { + embeds: [embed], + components: [ + new ActionRowBuilder().addComponents([ + new ButtonBuilder() + .setLabel('Steam Post') + .setStyle(ButtonStyle.Link) + .setURL(p.url), + ]), + ], + }; + }); + + messages.forEach(async (m, i) => { + if (i === 0) await interaction.editReply({...m}); + else await interaction.followUp({...m, ephemeral: true}); + }); +} + +export default command; diff --git a/src/commands/superstore.ts b/src/commands/superstore.ts new file mode 100644 index 0000000..c787088 --- /dev/null +++ b/src/commands/superstore.ts @@ -0,0 +1,75 @@ +import {EmbedBuilder, SlashCommandBuilder} from 'discord.js'; +import {Command} from '../interfaces'; +import {FOOTER_MESSAGE} from './_components'; +import {data} from '../api-wrapper'; +import {dayjs} from '../handlers/dates'; + +const command: Command = { + data: new SlashCommandBuilder() + .setName('superstore') + .setDescription('Check the current stock of the super store!'), + run: async interaction => { + const {SuperStore} = data; + if (!SuperStore || !SuperStore.items) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setTitle('Super Store') + .setDescription( + 'Failed to retrieve Super Store data. Please try again later!' + ) + .setFooter({text: FOOTER_MESSAGE}), + ], + }); + return; + } + const embeds: EmbedBuilder[] = []; + for (const item of SuperStore.items) { + const embed = new EmbedBuilder() + .setTitle(`${item.name} (${item.type} ${item.slot})`) + .setDescription(item.description) + .addFields( + { + name: 'Armour', + value: `${item.armor_rating}`, + inline: true, + }, + { + name: 'Speed', + value: `${item.speed}`, + inline: true, + }, + { + name: 'Stamina Regen', + value: `${item.stamina_regen}`, + inline: true, + }, + { + name: `Passive: ${item.passive.name}`, + value: item.passive.description, + inline: false, + } + ); + embeds.push(embed); + } + + // add a final embed for the expiration time + const expireTimeS = dayjs(SuperStore.expire_time).unix(); + const expiresIn = dayjs(SuperStore.expire_time).diff(); + const expiresInH = Math.floor(expiresIn / 3600000); + const remainingMinutes = Math.floor((expiresIn % 3600000) / 60000); + + embeds.push( + new EmbedBuilder() + .setTitle('Super Store') + .setDescription( + `This store rotation will expire in **${expiresInH} hours, ${remainingMinutes} minutes** ()` + + '\n\n Data provided by **[Diveharder](https://api.diveharder.com/docs)**.' + ) + .setFooter({text: FOOTER_MESSAGE}) + ); + await interaction.editReply({embeds: embeds}); + }, +}; + +export default command; diff --git a/src/events/onReady.ts b/src/events/onReady.ts index bd4e57d..ce322f5 100644 --- a/src/events/onReady.ts +++ b/src/events/onReady.ts @@ -1,24 +1,9 @@ -import { - ActivityType, - ButtonBuilder, - ButtonStyle, - Client, - REST, - Routes, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, -} from 'discord.js'; +import {ActivityType, Client, REST, Routes} from 'discord.js'; import {schedule} from 'node-cron'; import {commandHash, commandList, presenceCmds, wikiCmd} from '../commands'; import {config} from '../config'; import {getData, mappedNames} from '../api-wrapper'; -import { - compareData, - dbData, - loadWikiFiles, - logger, - updateMessages, -} from '../handlers'; +import {compareData, dbData, logger, updateMessages} from '../handlers'; // bot client token, for use with discord API const BOT_TOKEN = config.BOT_TOKEN; @@ -76,47 +61,47 @@ const onReady = async (client: Client) => { }); // load wiki pages - const wiki = loadWikiFiles('./wiki'); - wikiCmd.buttons = wiki.categories.map(c => { - const {directory, display_name, content, emoji, thumbnail, image} = c; - - const button = new ButtonBuilder() - .setCustomId(directory) - .setLabel(display_name) - .setStyle(ButtonStyle.Secondary); - - if (emoji) button.setEmoji(emoji); - return button; - }); - wikiCmd.dirSelect = wiki.categories.reduce( - (acc, c) => { - acc[c.directory] = new StringSelectMenuBuilder() - .setCustomId(c.directory) - .setPlaceholder('Select a page from this category...') - .addOptions( - wiki.pages - .filter(page => page.page.startsWith(c.directory)) - .map(page => { - const option = new StringSelectMenuOptionBuilder() - .setLabel(page.title) - .setValue(page.page); - - if (page.description) option.setDescription(page.description); - if (page.emoji) option.setEmoji(page.emoji); - return option; - }) - ); - return acc; - }, - {} as Record - ); - wikiCmd.pages = wiki.pages; - wikiCmd.categories = wiki.categories; - - time = `${Date.now() - start}ms`; - logger.info(`Loaded ${wiki.pages.length} wiki pages in ${time}`, { - type: 'startup', - }); + // const wiki = loadWikiFiles('./wiki'); + // wikiCmd.buttons = wiki.categories.map(c => { + // const {directory, display_name, content, emoji, thumbnail, image} = c; + // + // const button = new ButtonBuilder() + // .setCustomId(directory) + // .setLabel(display_name) + // .setStyle(ButtonStyle.Secondary); + // + // if (emoji) button.setEmoji(emoji); + // return button; + // }); + // wikiCmd.dirSelect = wiki.categories.reduce( + // (acc, c) => { + // acc[c.directory] = new StringSelectMenuBuilder() + // .setCustomId(c.directory) + // .setPlaceholder('Select a page from this category...') + // .addOptions( + // wiki.pages + // .filter(page => page.page.startsWith(c.directory)) + // .map(page => { + // const option = new StringSelectMenuOptionBuilder() + // .setLabel(page.title) + // .setValue(page.page); + // + // if (page.description) option.setDescription(page.description); + // if (page.emoji) option.setEmoji(page.emoji); + // return option; + // }) + // ); + // return acc; + // }, + // {} as Record + // ); + // wikiCmd.pages = wiki.pages; + // wikiCmd.categories = wiki.categories; + // + // time = `${Date.now() - start}ms`; + // logger.info(`Loaded ${wiki.pages.length} wiki pages in ${time}`, { + // type: 'startup', + // }); // cron schedule to update messages schedule(PERSISTENT_MESSAGE_INTERVAL, () => updateMessages()); diff --git a/src/handlers/cron/deliverUpdates.ts b/src/handlers/cron/deliverUpdates.ts index b2e03f6..41e7faf 100644 --- a/src/handlers/cron/deliverUpdates.ts +++ b/src/handlers/cron/deliverUpdates.ts @@ -320,7 +320,7 @@ export async function newEventUpdate(event: GlobalEvent, channelIds: string[]) { return; } // TODO: use new endpoint to get this -// export async function newMajorOrdeUpdater(order: ??, channels: (TextChannel | PublicThreadChannel)[]) {} +// export async function newMajorOrderUpdater(order: ??, channels: (TextChannel | PublicThreadChannel)[]) {} export async function newMajorOrderUpdate( assignment: Assignment, channelIds: string[] @@ -346,47 +346,56 @@ export async function newMajorOrderUpdate( export async function newNewsUpdate(news: NewsFeedItem, channelIds: string[]) { const channels = await validateChannelArr(channelIds); const {message} = news; - const embeds = message.includes('\n') - ? [ - new EmbedBuilder() - .setAuthor({ - name: 'New Dispatch from SE Command!', - iconURL: altSprites['Humans'], - }) - .setTitle( - message - .split('\n')[0] - .replace(//g, '**') - .replace(/<\/i>/g, '**') - ) - .setDescription( - message - .split('\n') - .slice(1) - .join('\n') - .replace(//g, '**') - .replace(/<\/i>/g, '**') - ) - .setFooter({text: SUBSCRIBE_FOOTER}) - .setTimestamp(), - ] - : [ - new EmbedBuilder() - .setAuthor({ - name: 'New Dispatch from SE Command!', - iconURL: altSprites['Humans'], - }) - .setDescription( - news.message.replace(//g, '**').replace(/<\/i>/g, '**') - ) - .setFooter({text: SUBSCRIBE_FOOTER}) - .setTimestamp(), - ]; + const splitMessage = message.split('\n'); + // check whether the dispatch reasonably has a title (short string before newline) + const title = splitMessage[0].length < 256 ? splitMessage[0] : undefined; + const embeds: EmbedBuilder[] = []; + + // if it does have a title, parse it and include it as an embed title + if (title) + embeds.push( + new EmbedBuilder() + .setAuthor({ + name: 'New Dispatch from SE Command!', + iconURL: altSprites['Humans'], + }) + .setTitle( + message + .split('\n')[0] + .replace(//g, '**') + .replace(/<\/i>/g, '**') + ) + .setDescription( + message + .split('\n') + .slice(1) + .join('\n') + .replace(//g, '**') + .replace(/<\/i>/g, '**') + ) + .setFooter({text: SUBSCRIBE_FOOTER}) + .setTimestamp() + ); + // otherwise, no title, just description + else + embeds.push( + new EmbedBuilder() + .setAuthor({ + name: 'New Dispatch from SE Command!', + iconURL: altSprites['Humans'], + }) + .setDescription( + news.message.replace(//g, '**').replace(/<\/i>/g, '**') + ) + .setFooter({text: SUBSCRIBE_FOOTER}) + .setTimestamp() + ); for (const channel of channels) { try { const message = await channel.send({embeds}); - if (channel.type === ChannelType.GuildAnnouncement) message.crosspost(); + if (channel.type === ChannelType.GuildAnnouncement) + await message.crosspost(); } catch (err) { logger.error(err); }