diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md index ad690d2f..204f64cb 100644 --- a/.github/CHANGELOG.md +++ b/.github/CHANGELOG.md @@ -15,6 +15,7 @@ Update _ October 2024 - fix: Changing A32NX Releases to Aircraft Releases in Role assignment (30/10/2024) - fix: Bugs with permissions causing crash during startup or prefix command handling (30/10/2024) - feat: Prefix Command Management (28/10/2024) + - fix: role assignment typo for server announcements (22/10/2024) Update _ August 2024 diff --git a/package-lock.json b/package-lock.json index a10096ac..366c6ff6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "moment": "^2.29.4", "mongoose": "^8.0.3", "node-fetch": "^2.6.10", - "winston": "^3.3.4" + "winston": "^3.3.4", + "zod": "^3.23.8" }, "devDependencies": { "@flybywiresim/eslint-config": "^0.1.0", @@ -7767,6 +7768,14 @@ "engines": { "node": ">=6" } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index ec58a2b3..b13d2209 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "moment": "^2.29.4", "mongoose": "^8.0.3", "node-fetch": "^2.6.10", - "winston": "^3.3.4" + "winston": "^3.3.4", + "zod": "^3.23.8" }, "devDependencies": { "@flybywiresim/eslint-config": "^0.1.0", diff --git a/src/commands/utils/liveFlights.ts b/src/commands/utils/liveFlights.ts index cc0e0b96..8d4e3844 100644 --- a/src/commands/utils/liveFlights.ts +++ b/src/commands/utils/liveFlights.ts @@ -1,5 +1,6 @@ import { ApplicationCommandType, Colors } from 'discord.js'; -import { slashCommand, slashCommandStructure, makeEmbed, Logger } from '../../lib'; +import { ZodError } from 'zod'; +import { slashCommand, slashCommandStructure, makeEmbed, Logger, fetchForeignAPI, TelexCountSchema } from '../../lib'; const data = slashCommandStructure({ name: 'live-flights', @@ -11,8 +12,10 @@ const FBW_WEB_MAP_URL = 'https://flybywiresim.com/map'; const FBW_API_BASE_URL = 'https://api.flybywiresim.com'; export default slashCommand(data, async ({ interaction }) => { + await interaction.deferReply(); + try { - const flights = await fetch(`${FBW_API_BASE_URL}/txcxn/_count`).then((res) => res.json()); + const flights = await fetchForeignAPI(`${FBW_API_BASE_URL}/txcxn/_count`, TelexCountSchema); const flightsEmbed = makeEmbed({ title: 'Live Flights', description: `There are currently **${flights}** active flights with TELEX enabled.`, @@ -20,8 +23,16 @@ export default slashCommand(data, async ({ interaction }) => { url: FBW_WEB_MAP_URL, timestamp: new Date().toISOString(), }); - return interaction.reply({ embeds: [flightsEmbed] }); + return interaction.editReply({ embeds: [flightsEmbed] }); } catch (e) { + if (e instanceof ZodError) { + const errorEmbed = makeEmbed({ + title: 'TELEX Error', + description: 'The API returned unknown data.', + color: Colors.Red, + }); + return interaction.editReply({ embeds: [errorEmbed] }); + } const error = e as Error; Logger.error(error); const errorEmbed = makeEmbed({ @@ -29,6 +40,6 @@ export default slashCommand(data, async ({ interaction }) => { description: error.message, color: Colors.Red, }); - return interaction.reply({ embeds: [errorEmbed] }); + return interaction.editReply({ embeds: [errorEmbed] }); } }); diff --git a/src/commands/utils/metar.ts b/src/commands/utils/metar.ts index dd688e48..2cc42e3f 100644 --- a/src/commands/utils/metar.ts +++ b/src/commands/utils/metar.ts @@ -1,6 +1,7 @@ import { ApplicationCommandOptionType, ApplicationCommandType, Colors } from 'discord.js'; -import fetch from 'node-fetch'; -import { constantsConfig, slashCommand, slashCommandStructure, makeEmbed, makeLines, Logger } from '../../lib'; +import { Request } from 'node-fetch'; +import { ZodError } from 'zod'; +import { constantsConfig, fetchForeignAPI, makeEmbed, makeLines, slashCommand, slashCommandStructure, Metar, MetarSchema, Logger } from '../../lib'; const data = slashCommandStructure({ name: 'metar', @@ -16,6 +17,12 @@ const data = slashCommandStructure({ }], }); +const errorEmbed = (error: string) => makeEmbed({ + title: 'METAR Error', + description: error, + color: Colors.Red, +}); + export default slashCommand(data, async ({ interaction }) => { await interaction.deferReply(); @@ -32,55 +39,45 @@ export default slashCommand(data, async ({ interaction }) => { return interaction.editReply({ embeds: [noTokenEmbed] }); } + let metar: Metar; try { - const metarReport: any = await fetch(`https://avwx.rest/api/metar/${icao}`, { + metar = await fetchForeignAPI(new Request(`https://avwx.rest/api/metar/${icao}`, { method: 'GET', headers: { Authorization: metarToken }, - }) - .then((res) => res.json()); - - if (metarReport.error) { - const invalidEmbed = makeEmbed({ - title: `Metar Error | ${icao.toUpperCase()}`, - description: metarReport.error, - color: Colors.Red, - }); - return interaction.editReply({ embeds: [invalidEmbed] }); - } - const metarEmbed = makeEmbed({ - title: `METAR Report | ${metarReport.station}`, - description: makeLines([ - '**Raw Report**', - metarReport.raw, - '', - '**Basic Report:**', - `**Time Observed:** ${metarReport.time.dt}`, - `**Station:** ${metarReport.station}`, - `**Wind:** ${metarReport.wind_direction.repr}${metarReport.wind_direction.repr === 'VRB' ? '' : constantsConfig.units.DEGREES} at ${metarReport.wind_speed.repr} ${metarReport.units.wind_speed}`, - `**Visibility:** ${metarReport.visibility.repr} ${Number.isNaN(+metarReport.visibility.repr) ? '' : metarReport.units.visibility}`, - `**Temperature:** ${metarReport.temperature.repr} ${constantsConfig.units.CELSIUS}`, - `**Dew Point:** ${metarReport.dewpoint.repr} ${constantsConfig.units.CELSIUS}`, - `**Altimeter:** ${metarReport.altimeter.value.toString()} ${metarReport.units.altimeter}`, - `**Flight Rules:** ${metarReport.flight_rules}`, - ]), - fields: [ - { - name: 'Unsure of how to read the raw report?', - value: 'Please refer to our guide [here.](https://docs.flybywiresim.com/pilots-corner/airliner-flying-guide/weather/)', - inline: false, - }, - ], - footer: { text: 'This METAR report may not accurately reflect the weather in the simulator. However, it will always be similar to the current conditions present in the sim.' }, - }); - - return interaction.editReply({ embeds: [metarEmbed] }); + }), MetarSchema); } catch (e) { - Logger.error('metar:', e); - const fetchErrorEmbed = makeEmbed({ - title: 'Metar Error | Fetch Error', - description: 'There was an error fetching the METAR report. Please try again later.', - color: Colors.Red, - }); - return interaction.editReply({ embeds: [fetchErrorEmbed] }); + if (e instanceof ZodError) { + return interaction.editReply({ embeds: [errorEmbed('The API returned unknown data.')] }); + } + Logger.error(`Error occured while fetching METAR: ${String(e)}`); + return interaction.editReply({ embeds: [errorEmbed(`An error occurred while fetching the latest METAR for ${icao.toUpperCase()}.`)] }); } + + const metarEmbed = makeEmbed({ + title: `METAR Report | ${metar.station}`, + description: makeLines([ + '**Raw Report**', + metar.raw, + '', + '**Basic Report:**', + `**Time Observed:** ${metar.time.dt}`, + `**Station:** ${metar.station}`, + `**Wind:** ${metar.wind_direction.repr}${metar.wind_direction.repr === 'VRB' ? '' : constantsConfig.units.DEGREES} at ${metar.wind_speed.repr} ${metar.units.wind_speed}`, + `**Visibility:** ${metar.visibility.repr} ${Number.isNaN(+metar.visibility.repr) ? '' : metar.units.visibility}`, + `**Temperature:** ${metar.temperature.repr} ${constantsConfig.units.CELSIUS}`, + `**Dew Point:** ${metar.dewpoint.repr} ${constantsConfig.units.CELSIUS}`, + `**Altimeter:** ${metar.altimeter.value.toString()} ${metar.units.altimeter}`, + `**Flight Rules:** ${metar.flight_rules}`, + ]), + fields: [ + { + name: 'Unsure of how to read the raw report?', + value: 'Please refer to our guide [here](https://docs.flybywiresim.com/pilots-corner/airliner-flying-guide/weather/).', + inline: false, + }, + ], + footer: { text: 'This METAR report may not accurately reflect the weather in the simulator. However, it will always be similar to the current conditions present in the sim.' }, + }); + + return interaction.editReply({ embeds: [metarEmbed] }); }); diff --git a/src/commands/utils/simbriefData.ts b/src/commands/utils/simbriefData.ts index b7d818a1..4fb47dd6 100644 --- a/src/commands/utils/simbriefData.ts +++ b/src/commands/utils/simbriefData.ts @@ -1,6 +1,7 @@ import { ApplicationCommandOptionType, ApplicationCommandType, Colors } from 'discord.js'; import moment from 'moment'; -import { slashCommand, makeEmbed, makeLines, slashCommandStructure } from '../../lib'; +import { ZodError } from 'zod'; +import { fetchForeignAPI, Logger, makeEmbed, makeLines, SimbriefFlightPlan, SimbriefFlightPlanSchema, slashCommand, slashCommandStructure } from '../../lib'; const data = slashCommandStructure({ name: 'simbrief-data', @@ -40,9 +41,9 @@ const simbriefdatarequestEmbed = makeEmbed({ ]), }); -const errorEmbed = (errorMessage: any) => makeEmbed({ +const errorEmbed = (error: string) => makeEmbed({ title: 'SimBrief Error', - description: makeLines(['SimBrief data could not be read.', errorMessage]), + description: error, color: Colors.Red, }); @@ -53,10 +54,10 @@ const simbriefIdMismatchEmbed = (enteredId: any, flightplanId: any) => makeEmbed ]), }); -const simbriefEmbed = (flightplan: any) => makeEmbed({ +const simbriefEmbed = (flightplan: SimbriefFlightPlan) => makeEmbed({ title: 'SimBrief Data', description: makeLines([ - `**Generated at**: ${moment(flightplan.params.time_generated * 1000).format('DD.MM.YYYY, HH:mm:ss')}`, + `**Generated at**: ${moment(Number.parseInt(flightplan.params.time_generated) * 1000).format('DD.MM.YYYY, HH:mm:ss')}`, `**AirFrame**: ${flightplan.aircraft.name} ${flightplan.aircraft.internal_id} ${(flightplan.aircraft.internal_id === FBW_AIRFRAME_ID) ? '(provided by FBW)' : ''}`, `**AIRAC Cycle**: ${flightplan.params.airac}`, `**Origin**: ${flightplan.origin.icao_code} ${flightplan.origin.plan_rwy}`, @@ -71,23 +72,33 @@ export default slashCommand(data, async ({ interaction }) => { return interaction.reply({ embeds: [simbriefdatarequestEmbed] }); } + await interaction.deferReply(); + if (interaction.options.getSubcommand() === 'retrieve') { const simbriefId = interaction.options.getString('pilot_id'); - if (!simbriefId) return interaction.reply({ content: 'Invalid pilot ID!', ephemeral: true }); + if (!simbriefId) return interaction.editReply({ content: 'Invalid pilot ID!' }); - const flightplan = await fetch(`https://www.simbrief.com/api/xml.fetcher.php?json=1&userid=${simbriefId}&username=${simbriefId}`).then((res) => res.json()); + let flightplan: SimbriefFlightPlan; + try { + flightplan = await fetchForeignAPI(`https://www.simbrief.com/api/xml.fetcher.php?json=1&userid=${simbriefId}&username=${simbriefId}`, SimbriefFlightPlanSchema); + } catch (e) { + if (e instanceof ZodError) { + return interaction.editReply({ embeds: [errorEmbed('The API returned unknown data.')] }); + } + Logger.error(`Error while fetching SimBrief flightplan: ${String(e)}`); + return interaction.editReply({ embeds: [errorEmbed('An error occurred while fetching the SimBrief flightplan.')] }); + } if (flightplan.fetch.status !== 'Success') { - interaction.reply({ embeds: [errorEmbed(flightplan.fetch.status)], ephemeral: true }); - return Promise.resolve(); + return interaction.editReply({ embeds: [errorEmbed(flightplan.fetch.status)] }); } if (!simbriefId.match(/\D/) && simbriefId !== flightplan.params.user_id) { - interaction.reply({ embeds: [simbriefIdMismatchEmbed(simbriefId, flightplan.params.user_id)] }); + return interaction.editReply({ embeds: [simbriefIdMismatchEmbed(simbriefId, flightplan.params.user_id)] }); } - interaction.reply({ embeds: [simbriefEmbed(flightplan)] }); - return Promise.resolve(); + return interaction.editReply({ embeds: [simbriefEmbed(flightplan)] }); } - return Promise.resolve(); + + return interaction.editReply({ content: 'Unknown subcommand.' }); }); diff --git a/src/commands/utils/station.ts b/src/commands/utils/station.ts index 8cde14a2..20275e08 100644 --- a/src/commands/utils/station.ts +++ b/src/commands/utils/station.ts @@ -1,6 +1,9 @@ import { ApplicationCommandOptionType, ApplicationCommandType, Colors } from 'discord.js'; -import fetch from 'node-fetch'; -import { slashCommand, slashCommandStructure, makeEmbed, Logger, makeLines } from '../../lib'; +import { Request } from 'node-fetch'; +import { z, ZodError } from 'zod'; +import { AVWXRunwaySchema, AVWXStation, AVWXStationSchema, fetchForeignAPI, Logger, makeEmbed, makeLines, slashCommand, slashCommandStructure } from '../../lib'; + +type Runway = z.infer; const data = slashCommandStructure({ name: 'station', @@ -22,6 +25,12 @@ const noQueryEmbed = makeEmbed({ color: Colors.Red, }); +const errorEmbed = (error: string) => makeEmbed({ + title: 'Station Error', + description: error, + color: Colors.Red, +}); + export default slashCommand(data, async ({ interaction }) => { await interaction.deferReply(); @@ -40,54 +49,44 @@ export default slashCommand(data, async ({ interaction }) => { if (!icao) return interaction.editReply({ embeds: [noQueryEmbed] }); + let station: AVWXStation; try { - const stationReport: any = await fetch(`https://avwx.rest/api/station/${icao}`, { + station = await fetchForeignAPI(new Request(`https://avwx.rest/api/station/${icao}`, { method: 'GET', headers: { Authorization: stationToken }, - }).then((res) => res.json()); - - if (stationReport.error) { - const invalidEmbed = makeEmbed({ - title: `Station Error | ${icao.toUpperCase()}`, - description: stationReport.error, - color: Colors.Red, - }); - return interaction.editReply({ embeds: [invalidEmbed] }); + }), AVWXStationSchema); + } catch (e) { + if (e instanceof ZodError) { + return interaction.editReply({ embeds: [errorEmbed('The API returned unknown data.')] }); } + Logger.error(`Error while fetching station info from AVWX: ${e}`); + return interaction.editReply({ embeds: [errorEmbed(`An error occurred while fetching the station information for ${icao.toUpperCase()}.`)] }); + } - const runwayIdents = stationReport.runways.map((runways: any) => `**${runways.ident1}/${runways.ident2}:** ` - + `${runways.length_ft} ft x ${runways.width_ft} ft / ` - + `${Math.round(runways.length_ft * 0.3048)} m x ${Math.round(runways.width_ft * 0.3048)} m`); + const runwayIdents = station.runways ? station.runways.map((runways: Runway) => `**${runways.ident1}/${runways.ident2}:** ` + + `${runways.length_ft} ft x ${runways.width_ft} ft / ` + + `${Math.round(runways.length_ft * 0.3048)} m x ${Math.round(runways.width_ft * 0.3048)} m`) : null; - const stationEmbed = makeEmbed({ - title: `Station Info | ${stationReport.icao}`, - description: makeLines([ - '**Station Information:**', - `**Name:** ${stationReport.name}`, - `**Country:** ${stationReport.country}`, - `**City:** ${stationReport.city}`, - `**Latitude:** ${stationReport.latitude}°`, - `**Longitude:** ${stationReport.longitude}°`, - `**Elevation:** ${stationReport.elevation_m} m/${stationReport.elevation_ft} ft`, - '', - '**Runways (Ident1/Ident2: Length x Width):**', - `${runwayIdents.toString().replace(/,/g, '\n')}`, - '', - `**Type:** ${stationReport.type.replace(/_/g, ' ')}`, - `**Website:** ${stationReport.website}`, - `**Wiki:** ${stationReport.wiki}`, - ]), - footer: { text: 'Due to limitations of the API, not all links may be up to date at all times.' }, - }); + const stationEmbed = makeEmbed({ + title: `Station Info | ${station.icao}`, + description: makeLines([ + '**Station Information:**', + `**Name:** ${station.name}`, + `**Country:** ${station.country}`, + `**City:** ${station.city}`, + `**Latitude:** ${station.latitude}°`, + `**Longitude:** ${station.longitude}°`, + `**Elevation:** ${station.elevation_m} m/${station.elevation_ft} ft`, + '', + '**Runways (Length x Width):**', + `${runwayIdents ? runwayIdents.toString().replace(/,/g, '\n') : 'N/A'}`, + '', + `**Type:** ${station.type.replace(/_/g, ' ')}`, + `**Website:** ${station.website ?? 'N/A'}`, + `**Wiki:** ${station.wiki ?? 'N/A'}`, + ]), + footer: { text: 'Due to limitations of the API, not all links may be up to date at all times.' }, + }); - return interaction.editReply({ embeds: [stationEmbed] }); - } catch (error) { - Logger.error('station:', error); - const fetchErrorEmbed = makeEmbed({ - title: 'Station Error | Fetch Error', - description: 'There was an error fetching the station report. Please try again later.', - color: Colors.Red, - }); - return interaction.editReply({ embeds: [fetchErrorEmbed] }); - } + return interaction.editReply({ embeds: [stationEmbed] }); }); diff --git a/src/commands/utils/taf.ts b/src/commands/utils/taf.ts index dba64551..96caab98 100644 --- a/src/commands/utils/taf.ts +++ b/src/commands/utils/taf.ts @@ -1,6 +1,7 @@ import { ApplicationCommandOptionType, ApplicationCommandType, Colors } from 'discord.js'; -import fetch from 'node-fetch'; -import { constantsConfig, slashCommand, slashCommandStructure, makeEmbed, makeLines, Logger } from '../../lib'; +import { Request } from 'node-fetch'; +import { ZodError } from 'zod'; +import { Logger, TAF, TafSchema, fetchForeignAPI, makeEmbed, makeLines, slashCommand, slashCommandStructure } from '../../lib'; const data = slashCommandStructure({ name: 'taf', @@ -22,6 +23,12 @@ const noQueryEmbed = makeEmbed({ color: Colors.Red, }); +const errorEmbed = (error: string) => makeEmbed({ + title: 'TAF Error', + description: error, + color: Colors.Red, +}); + export default slashCommand(data, async ({ interaction }) => { await interaction.deferReply(); @@ -42,61 +49,37 @@ export default slashCommand(data, async ({ interaction }) => { return interaction.editReply({ embeds: [noQueryEmbed] }); } + let taf: TAF; try { - const tafReport: any = await fetch(`https://avwx.rest/api/taf/${icao}`, { + taf = await fetchForeignAPI(new Request(`https://avwx.rest/api/taf/${icao}`, { method: 'GET', headers: { Authorization: tafToken }, - }).then((res) => res.json()); - - if (tafReport.error) { - const invalidEmbed = makeEmbed({ - title: `TAF Error | ${icao.toUpperCase()}`, - description: tafReport.error, - color: Colors.Red, - }); - return interaction.editReply({ embeds: [invalidEmbed] }); + }), TafSchema); + } catch (e) { + if (e instanceof ZodError) { + return interaction.editReply({ embeds: [errorEmbed('The API returned unknown data.')] }); } - const getClouds = (clouds: any) => { - const retClouds = []; - for (const cloud of clouds) { - retClouds.push(cloud.repr); - } - return retClouds.join(', '); - }; - const tafEmbed = makeEmbed({ - title: `TAF Report | ${tafReport.station}`, - description: makeLines([ - '**Raw Report**', - tafReport.raw, + Logger.error(`Error while fetching TAF from AVWX: ${e}`); + return interaction.editReply({ embeds: [errorEmbed(`An error occurred while fetching the latest TAF for ${icao.toUpperCase()}.`)] }); + } - '', - '**Basic Report:**', - `**Time Forecasted:** ${tafReport.time.dt}`, - `**Forecast Start Time:** ${tafReport.start_time.dt}`, - `**Forecast End Time:** ${tafReport.end_time.dt}`, - `**Visibility:** ${tafReport.forecast[0].visibility.repr} ${Number.isNaN(+tafReport.forecast[0].visibility.repr) ? '' : tafReport.units.visibility}`, - `**Wind:** ${tafReport.forecast[0].wind_direction.repr}${tafReport.forecast[0].wind_direction.repr === 'VRB' ? '' : constantsConfig.units.DEGREES} at ${tafReport.forecast[0].wind_speed.repr} ${tafReport.units.wind_speed}`, - `**Clouds:** ${getClouds(tafReport.forecast[0].clouds)}`, - `**Flight Rules:** ${tafReport.forecast[0].flight_rules}`, - ]), - fields: [ - { - name: 'Unsure of how to read the raw report?', - value: 'Please refer to our guide [here.](https://docs.flybywiresim.com/pilots-corner/airliner-flying-guide/weather/#taf-example-decoded)', - inline: false, - }, - ], - footer: { text: 'This TAF report is only a forecast, and may not accurately reflect weather in the simulator.' }, - }); + const tafEmbed = makeEmbed({ + title: `TAF Report | ${taf.station}`, + description: makeLines(['**Raw Report**', ...taf.forecast.map((forecast, i) => { + if (i === 0) { + return `${taf.station} ${forecast.raw}`; + } + return forecast.raw; + })]), + fields: [ + { + name: 'Unsure of how to read the report?', + value: `Please refer to our guide [here](https://docs.flybywiresim.com/pilots-corner/airliner-flying-guide/weather/#taf-example-decoded) or see above report decoded [here](https://e6bx.com/weather/${taf.station}/?showDecoded=1&focuspoint=tafdecoder).`, + inline: false, + }, + ], + footer: { text: 'This TAF report is only a forecast, and may not accurately reflect weather in the simulator.' }, + }); - return interaction.editReply({ embeds: [tafEmbed] }); - } catch (error) { - Logger.error('taf:', error); - const fetchErrorEmbed = makeEmbed({ - title: 'TAF Error | Fetch Error', - description: 'There was an error fetching the TAF report. Please try again later.', - color: Colors.Red, - }); - return interaction.editReply({ embeds: [fetchErrorEmbed] }); - } + return interaction.editReply({ embeds: [tafEmbed] }); }); diff --git a/src/commands/utils/vatsim/functions/vatsimControllers.ts b/src/commands/utils/vatsim/functions/vatsimControllers.ts index 08e5cd2b..a90f7fb7 100644 --- a/src/commands/utils/vatsim/functions/vatsimControllers.ts +++ b/src/commands/utils/vatsim/functions/vatsimControllers.ts @@ -1,5 +1,9 @@ import { ChatInputCommandInteraction, EmbedField } from 'discord.js'; -import { makeEmbed } from '../../../../lib'; +import { z } from 'zod'; +import { VatsimAtisSchema, VatsimRatingSchema, VatsimData, makeEmbed } from '../../../../lib'; + +type Atis = z.infer; +type Rating = z.infer; /* eslint-disable camelcase */ @@ -9,7 +13,7 @@ const listEmbed = (type: string, fields: EmbedField[], totalCount: number, shown fields, }); -const controllersListEmbedFields = (callsign: string, frequency: string, logon: string, rating: string, atis: string, atisCode: string): EmbedField[] => { +const controllersListEmbedFields = (callsign: string, frequency: string, logon: string, rating?: Rating, atis?: Atis): EmbedField[] => { const fields = [ { name: 'Callsign', @@ -26,22 +30,20 @@ const controllersListEmbedFields = (callsign: string, frequency: string, logon: value: `${logon}`, inline: true, }, - { + ]; + + if (rating) { + fields.push({ name: 'Rating', - value: `${rating}`, + value: `${rating.short} - ${rating.long}`, inline: true, - }, - ]; - if (atis !== null) { - let atisTitle = 'Info'; - if (atisCode) { - atisTitle = `ATIS - Code: ${atisCode}`; - } else if (atisCode !== undefined) { - atisTitle = 'ATIS'; - } + }); + } + + if (atis && atis.text_atis) { fields.push({ - name: atisTitle, - value: atis, + name: `ATIS - ${atis.atis_code ? atis.atis_code : 'N/A'}`, + value: atis.text_atis.join('\n'), inline: false, }); } @@ -57,30 +59,17 @@ const handleLocaleDateTimeString = (date: Date) => date.toLocaleDateString('en-U day: 'numeric', }); -export async function handleVatsimControllers(interaction: ChatInputCommandInteraction<'cached'>, vatsimData: any, callsignSearch: any) { - const vatsimAllControllers = vatsimData.controllers ? vatsimData.controllers.filter((controller: { facility: number; }) => controller.facility > 0) : null; - - const vatsimControllerRatings = vatsimData.ratings ? vatsimData.ratings : null; - const vatsimControllers = vatsimAllControllers ? vatsimAllControllers.filter((controller: { callsign: string | string[]; }) => controller.callsign.includes(callsignSearch)) : null; - const vatsimAtis = vatsimData.atis ? vatsimData.atis.filter((atis: { callsign: string | string[]; }) => atis.callsign.includes(callsignSearch)) : null; - - const { keys }: ObjectConstructor = Object; - - const fields: EmbedField[] = [...vatsimControllers.sort((a: { facility: number; }, b: { facility: number; }) => b.facility - a.facility), ...vatsimAtis] - .map((vatsimController) => { - const { callsign, frequency, logon_time, atis_code, text_atis, rating } = vatsimController; - const logonTime = new Date(logon_time); - const logonTimeString = handleLocaleDateTimeString(logonTime); - const ratingDetail = vatsimControllerRatings.filter((ratingInfo: { id: any; }) => ratingInfo.id === rating); - const { short, long } = ratingDetail[0]; - const ratingText = `${short} - ${long}`; - const atisText = text_atis ? text_atis.join('\n') : null; +export async function handleVatsimControllers(interaction: ChatInputCommandInteraction<'cached'>, vatsimData: VatsimData, callsignSearch: string) { + const controllers = vatsimData.controllers.filter((controller) => controller.facility > 0 && controller.callsign.includes(callsignSearch)); + controllers.sort((a, b) => b.facility - a.facility); - return controllersListEmbedFields(callsign, frequency, logonTimeString, ratingText, atisText, atis_code); - }).slice(0, 5).flat(); + const fields = controllers.map((controller) => { + const { callsign, frequency, logon_time } = controller; + const rating = vatsimData.ratings.find((rating) => rating.id === controller.rating); + const atis = vatsimData.atis.find((atis) => atis.cid === controller.cid); - const totalCount = keys(vatsimControllers).length + keys(vatsimAtis).length; - const shownCount = totalCount < 5 ? totalCount : 5; + return controllersListEmbedFields(callsign, frequency, handleLocaleDateTimeString(new Date(logon_time)), rating, atis); + }).splice(0, 5); - return interaction.reply({ embeds: [listEmbed('Controllers & ATIS', fields, totalCount, shownCount, callsignSearch)] }); + return interaction.editReply({ embeds: [listEmbed('Controllers & ATIS', fields.flat(), controllers.length, fields.length, callsignSearch)] }); } diff --git a/src/commands/utils/vatsim/functions/vatsimEvents.ts b/src/commands/utils/vatsim/functions/vatsimEvents.ts index 468c8f83..73b96dd8 100644 --- a/src/commands/utils/vatsim/functions/vatsimEvents.ts +++ b/src/commands/utils/vatsim/functions/vatsimEvents.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction, Colors, EmbedField } from 'discord.js'; -import { Logger, makeEmbed } from '../../../../lib'; +import { Logger, VatsimEventsSchema, fetchForeignAPI, makeEmbed } from '../../../../lib'; const BASE_VATSIM_URL = 'https://my.vatsim.net'; @@ -15,16 +15,13 @@ const handleLocaleDateString = (date: Date) => date.toLocaleDateString('en-US', }); export async function handleVatsimEvents(interaction: ChatInputCommandInteraction<'cached'>) { - await interaction.deferReply(); - try { - const eventsList = await fetch(`${BASE_VATSIM_URL}/api/v1/events/all`) - .then((res) => res.json()) - .then((res) => res.data) - .then((res) => res.filter((event: { type: string; }) => event.type === 'Event')) - .then((res) => res.slice(0, 5)); + const response = await fetchForeignAPI(`${BASE_VATSIM_URL}/api/v1/events/all`, VatsimEventsSchema); + + const filteredEvents = response.data.filter((event) => event.type === 'Event'); + const finalList = filteredEvents.slice(0, 5); - const fields: EmbedField[] = eventsList.map((event: any) => { + const fields: EmbedField[] = finalList.map((event) => { // eslint-disable-next-line camelcase const { name, organisers, end_time, start_time, link } = event; const { division } = organisers[0]; @@ -71,11 +68,11 @@ export async function handleVatsimEvents(interaction: ChatInputCommandInteractio }); return interaction.editReply({ embeds: [eventsEmbed] }); - } catch (error: any) { - Logger.error(error); + } catch (e) { + Logger.error(e); const errorEmbed = makeEmbed({ title: 'Events Error', - description: error.message, + description: String(e), color: Colors.Red, }); return interaction.editReply({ embeds: [errorEmbed] }); diff --git a/src/commands/utils/vatsim/functions/vatsimObservers.ts b/src/commands/utils/vatsim/functions/vatsimObservers.ts index 48fd6ed1..9ad8cd8b 100644 --- a/src/commands/utils/vatsim/functions/vatsimObservers.ts +++ b/src/commands/utils/vatsim/functions/vatsimObservers.ts @@ -1,5 +1,8 @@ import { ChatInputCommandInteraction, EmbedField } from 'discord.js'; -import { makeEmbed } from '../../../../lib'; +import { z } from 'zod'; +import { VatsimRatingSchema, VatsimData, makeEmbed } from '../../../../lib'; + +type Rating = z.infer; /* eslint-disable camelcase */ @@ -9,7 +12,7 @@ const listEmbed = (type: string, fields: EmbedField[], totalCount: number, shown fields, }); -const observersListEmbedFields = (callsign: string, logon: string, rating: string, atis: string): EmbedField[] => { +const observersListEmbedFields = (callsign: string, logon: string, rating?: Rating): EmbedField[] => { const fields = [ { name: 'Callsign', @@ -21,18 +24,13 @@ const observersListEmbedFields = (callsign: string, logon: string, rating: strin value: `${logon}`, inline: true, }, - { - name: 'Rating', - value: `${rating}`, - inline: true, - }, ]; - if (atis !== null) { - const atisTitle = 'Info'; + + if (rating) { fields.push({ - name: atisTitle, - value: atis, - inline: false, + name: 'Rating', + value: `${rating.short} - ${rating.long}`, + inline: true, }); } @@ -47,28 +45,15 @@ const handleLocaleDateTimeString = (date: Date) => date.toLocaleDateString('en-U day: 'numeric', }); -export async function handleVatsimObservers(interaction: ChatInputCommandInteraction<'cached'>, vatsimData: any, callsignSearch: any) { - const vatsimAllObservers = vatsimData.controllers ? vatsimData.controllers.filter((controller: { facility: number; }) => controller.facility <= 0) : null; - - const vatsimControllerRatings = vatsimData.ratings ? vatsimData.ratings : null; - const vatsimObservers = vatsimAllObservers ? vatsimAllObservers.filter((observer: { callsign: string | any[]; }) => observer.callsign.includes(callsignSearch)) : null; - - const { keys }: ObjectConstructor = Object; - - const fields: EmbedField[] = [...vatsimObservers.sort((a: { rating: number; }, b: { rating: number; }) => b.rating - a.rating)].map((vatsimObserver) => { - const { callsign, logon_time, text_atis, rating } = vatsimObserver; - const logonTime = new Date(logon_time); - const logonTimeString = handleLocaleDateTimeString(logonTime); - const ratingDetail = vatsimControllerRatings.filter((ratingInfo: { id: any; }) => ratingInfo.id === rating); - const { short, long } = ratingDetail[0]; - const ratingText = `${short} - ${long}`; - const atisText = text_atis ? text_atis.join('\n') : null; +export async function handleVatsimObservers(interaction: ChatInputCommandInteraction<'cached'>, vatsimData: VatsimData, callsignSearch: string) { + const observers = vatsimData.controllers.filter((controller) => controller.facility <= 0 && controller.callsign.includes(callsignSearch)); - return observersListEmbedFields(callsign, logonTimeString, ratingText, atisText); - }).slice(0, 5).flat(); + const fields = observers.map((observer) => { + const { callsign, logon_time } = observer; + const rating = vatsimData.ratings.find((rating) => rating.id === observer.rating); - const totalCount = keys(vatsimObservers).length; - const shownCount = totalCount < 5 ? totalCount : 5; + return observersListEmbedFields(callsign, handleLocaleDateTimeString(new Date(logon_time)), rating); + }).splice(0, 5); - return interaction.reply({ embeds: [listEmbed('Observers', fields, totalCount, shownCount, callsignSearch)] }); + return interaction.editReply({ embeds: [listEmbed('Observers', fields.flat(), observers.length, fields.length, callsignSearch)] }); } diff --git a/src/commands/utils/vatsim/functions/vatsimPilots.ts b/src/commands/utils/vatsim/functions/vatsimPilots.ts index e73c6300..70d9cf1f 100644 --- a/src/commands/utils/vatsim/functions/vatsimPilots.ts +++ b/src/commands/utils/vatsim/functions/vatsimPilots.ts @@ -1,5 +1,9 @@ import { ChatInputCommandInteraction, EmbedField } from 'discord.js'; -import { makeEmbed } from '../../../../lib'; +import { z } from 'zod'; +import { VatsimFlightPlanSchema, VatsimPilotRatingSchema, VatsimData, makeEmbed } from '../../../../lib'; + +type PilotRating = z.infer; +type FlightPlan = z.infer; /* eslint-disable camelcase */ @@ -8,21 +12,24 @@ const listEmbed = (type: string, fields: EmbedField[], totalCount: number, shown description: `A list of ${shownCount} online ${type} matching ${callsign}.`, fields, }); -const pilotsListEmbedFields = (callsign: string, rating: string, flightPlan: any) => { +const pilotsListEmbedFields = (callsign: string, flightPlan: FlightPlan | null, rating?: PilotRating) => { const fields = [ { name: 'Callsign', value: callsign, inline: false, }, - { + ]; + + if (rating) { + fields.push({ name: 'Rating', - value: rating, + value: `${rating.short_name} - ${rating.long_name}`, inline: true, - }, - ]; + }); + } - if (flightPlan !== null) { + if (flightPlan) { const { aircraft_short, departure, arrival } = flightPlan; fields.push( { @@ -41,23 +48,16 @@ const pilotsListEmbedFields = (callsign: string, rating: string, flightPlan: any return fields; }; -export async function handleVatsimPilots(interaction: ChatInputCommandInteraction<'cached'>, vatsimData: any, callsignSearch: any) { - const vatsimPilotRatings = vatsimData.pilot_ratings ? vatsimData.pilot_ratings : null; - const vatsimPilots = vatsimData.pilots ? vatsimData.pilots.filter((pilot: { callsign: (string | null)[]; }) => pilot.callsign.includes(callsignSearch)) : null; - - const { keys }: ObjectConstructor = Object; - - const fields: EmbedField[] = [...vatsimPilots.sort((a: { pilot_rating: number; }, b: { pilot_rating: number; }) => b.pilot_rating - a.pilot_rating)].map((vatsimPilot) => { - const { callsign, pilot_rating, flight_plan } = vatsimPilot; - const ratingDetail = vatsimPilotRatings.filter((ratingInfo: { id: number; }) => ratingInfo.id === pilot_rating); - const { short_name, long_name } = ratingDetail[0]; - const ratingText = `${short_name} - ${long_name}`; +export async function handleVatsimPilots(interaction: ChatInputCommandInteraction<'cached'>, vatsimData: VatsimData, callsignSearch: string) { + const pilots = vatsimData.pilots.filter((pilot) => pilot.callsign.includes(callsignSearch)); + pilots.sort((a, b) => b.pilot_rating - a.pilot_rating); - return pilotsListEmbedFields(callsign, ratingText, flight_plan); - }).slice(0, 5).flat(); + const fields = pilots.map((pilot) => { + const { callsign, flight_plan } = pilot; + const rating = vatsimData.pilot_ratings.find((rating) => rating.id === pilot.pilot_rating); - const totalCount = keys(vatsimPilots).length; - const shownCount = totalCount < 5 ? totalCount : 5; + return pilotsListEmbedFields(callsign, flight_plan, rating); + }).splice(0, 5); - return interaction.reply({ embeds: [listEmbed('Pilots', fields, totalCount, shownCount, callsignSearch)] }); + return interaction.editReply({ embeds: [listEmbed('Pilots', fields.flat(), pilots.length, fields.length, callsignSearch)] }); } diff --git a/src/commands/utils/vatsim/functions/vatsimStats.ts b/src/commands/utils/vatsim/functions/vatsimStats.ts index a37c7c82..dad5f63f 100644 --- a/src/commands/utils/vatsim/functions/vatsimStats.ts +++ b/src/commands/utils/vatsim/functions/vatsimStats.ts @@ -1,54 +1,47 @@ import { ChatInputCommandInteraction } from 'discord.js'; -import { makeEmbed } from '../../../../lib'; +import { VatsimData, makeEmbed } from '../../../../lib'; -const statsEmbed = (pilots: string, controllers: string, atis: string, observers: string, callsign: any) => makeEmbed({ +const statsEmbed = (pilots: number, controllers: number, atis: number, observers: number, callsign?: string) => makeEmbed({ title: callsign ? `VATSIM Data | Statistics for callsign ${callsign}` : 'VATSIM Data | Statistics', description: callsign ? `An overview of the current active Pilots, Controllers, ATIS and Observers matching ${callsign}.` : 'An overview of the current active Pilots, Controllers, ATIS and Observers.', fields: [ { name: 'Pilots', - value: pilots, + value: pilots.toString(), inline: true, }, { name: 'Controllers', - value: controllers, + value: controllers.toString(), inline: true, }, { name: 'ATIS', - value: atis, + value: atis.toString(), inline: true, }, { name: 'Observers', - value: observers, + value: observers.toString(), inline: true, }, ], }); -export async function handleVatsimStats(interaction: ChatInputCommandInteraction<'cached'>, vatsimData: any, callsignSearch: any) { - const vatsimAllControllers = vatsimData.controllers ? vatsimData.controllers.filter((controller: { facility: number; }) => controller.facility > 0) : null; - const vatsimAllObservers = vatsimData.controllers ? vatsimData.controllers.filter((controller: { facility: number; }) => controller.facility <= 0) : null; +export async function handleVatsimStats(interaction: ChatInputCommandInteraction<'cached'>, vatsimData: VatsimData, callsignSearch?: string) { + const controllers = vatsimData.controllers.filter((controller) => controller.facility > 0); + const observers = vatsimData.controllers.filter((controller) => controller.facility <= 0); + const { atis } = vatsimData; + const { pilots } = vatsimData; if (!callsignSearch) { - const vatsimPilotCount = vatsimData.pilots ? vatsimData.pilots.length : 0; - const vatsimControllerCount = vatsimAllControllers ? vatsimAllControllers.length : 0; - const vatsimAtisCount = vatsimData.atis ? vatsimData.atis.length : 0; - const vatsimObserverCount = vatsimAllObservers ? vatsimAllObservers.length : 0; - - return interaction.reply({ embeds: [statsEmbed(vatsimPilotCount, vatsimControllerCount, vatsimAtisCount, vatsimObserverCount, null)] }); + return interaction.editReply({ embeds: [statsEmbed(pilots.length, controllers.length, atis.length, observers.length)] }); } - const vatsimPilots = vatsimData.pilots ? vatsimData.pilots.filter((pilot: { callsign: string | string[]; }) => pilot.callsign.includes(callsignSearch)) : null; - const vatsimControllers = vatsimAllControllers ? vatsimAllControllers.filter((controller: { callsign: string | string[]; }) => controller.callsign.includes(callsignSearch)) : null; - const vatsimAtis = vatsimData.atis ? vatsimData.atis.filter((atis: { callsign: string | string[]; }) => atis.callsign.includes(callsignSearch)) : null; - const vatsimObservers = vatsimAllObservers ? vatsimAllObservers.filter((observer: { callsign: string | string[]; }) => observer.callsign.includes(callsignSearch)) : null; - const vatsimPilotCount = vatsimPilots ? vatsimPilots.length : 0; - const vatsimControllerCount = vatsimControllers ? vatsimControllers.length : 0; - const vatsimAtisCount = vatsimAtis ? vatsimAtis.length : 0; - const vatsimObserverCount = vatsimObservers ? vatsimObservers.length : 0; + const matchedControllers = controllers.filter((controller) => controller.callsign.includes(callsignSearch)); + const matchedObservers = observers.filter((observer) => observer.callsign.includes(callsignSearch)); + const matchedPilots = pilots.filter((pilot) => pilot.callsign.includes(callsignSearch)); + const matchedAtis = atis.filter((atis) => atis.callsign.includes(callsignSearch)); - return interaction.reply({ embeds: [statsEmbed(vatsimPilotCount, vatsimControllerCount, vatsimAtisCount, vatsimObserverCount, callsignSearch)] }); + return interaction.editReply({ embeds: [statsEmbed(matchedPilots.length, matchedControllers.length, matchedAtis.length, matchedObservers.length, callsignSearch)] }); } diff --git a/src/commands/utils/vatsim/vatsim.ts b/src/commands/utils/vatsim/vatsim.ts index c1511937..d3dfbf66 100644 --- a/src/commands/utils/vatsim/vatsim.ts +++ b/src/commands/utils/vatsim/vatsim.ts @@ -1,10 +1,11 @@ import { ApplicationCommandOptionType, ApplicationCommandType, Colors } from 'discord.js'; -import { makeEmbed, slashCommand, slashCommandStructure } from '../../../lib'; -import { handleVatsimStats } from './functions/vatsimStats'; +import { ZodError } from 'zod'; +import { Logger, VatsimData, VatsimDataSchema, fetchForeignAPI, makeEmbed, slashCommand, slashCommandStructure } from '../../../lib'; import { handleVatsimControllers } from './functions/vatsimControllers'; -import { handleVatsimPilots } from './functions/vatsimPilots'; -import { handleVatsimObservers } from './functions/vatsimObservers'; import { handleVatsimEvents } from './functions/vatsimEvents'; +import { handleVatsimObservers } from './functions/vatsimObservers'; +import { handleVatsimPilots } from './functions/vatsimPilots'; +import { handleVatsimStats } from './functions/vatsimStats'; const data = slashCommandStructure({ name: 'vatsim', @@ -85,25 +86,23 @@ const fetchErrorEmbed = (error: any) => makeEmbed({ }); export default slashCommand(data, async ({ interaction }) => { - // Fetch VATSIM data + await interaction.deferReply(); - let vatsimData; + // Fetch VATSIM data + let vatsimData: VatsimData; try { - vatsimData = await fetch('https://data.vatsim.net/v3/vatsim-data.json').then((response) => { - if (response.ok) { - return response.json(); - } - throw new Error(response.statusText); - }); - } catch (error) { - await interaction.reply({ embeds: [fetchErrorEmbed(error)], ephemeral: true }); - return; + vatsimData = await fetchForeignAPI('https://data.vatsim.net/v3/vatsim-data.json', VatsimDataSchema); + } catch (e) { + if (e instanceof ZodError) { + return interaction.editReply({ embeds: [fetchErrorEmbed('The VATSIM API returned unknown data.')] }); + } + Logger.error(`Error while fetching VATSIM data: ${String(e)}.`); + return interaction.editReply({ embeds: [fetchErrorEmbed('An error occurred while fetching data from VATSIM.')] }); } // Grap the callsign from the interaction - let callsign = interaction.options.getString('callsign'); - let callsignSearch; + let callsignSearch: string | undefined; if (callsign) { callsign = callsign.toUpperCase(); @@ -112,35 +111,42 @@ export default slashCommand(data, async ({ interaction }) => { const regexMatches = callsign.match(regexCheck); if (!regexMatches || !regexMatches.groups || !regexMatches.groups.callsignSearch) { - // eslint-disable-next-line consistent-return - return interaction.reply({ content: 'You need to provide a valid callsign or part of a callsign to search for', ephemeral: true }); + return interaction.editReply({ content: 'You need to provide a valid callsign or part of a callsign to search for.' }); } callsignSearch = regexMatches.groups.callsignSearch; } // Handle the subcommands - const subcommandName = interaction.options.getSubcommand(); switch (subcommandName) { - case 'stats': - await handleVatsimStats(interaction, vatsimData, callsignSearch); - break; - case 'controllers': - await handleVatsimControllers(interaction, vatsimData, callsignSearch); - break; - case 'pilots': - await handleVatsimPilots(interaction, vatsimData, callsignSearch); - break; - case 'observers': - await handleVatsimObservers(interaction, vatsimData, callsignSearch); - break; - case 'events': - await handleVatsimEvents(interaction); - break; - - default: - await interaction.reply({ content: 'Unknown subcommand', ephemeral: true }); + case 'stats': { + return handleVatsimStats(interaction, vatsimData, callsignSearch); + } + case 'controllers': { + if (!callsignSearch) { + return interaction.editReply({ content: 'You need to provide a valid callsign or part of a callsign to search for.' }); + } + return handleVatsimControllers(interaction, vatsimData, callsignSearch); + } + case 'pilots': { + if (!callsignSearch) { + return interaction.editReply({ content: 'You need to provide a valid callsign or part of a callsign to search for.' }); + } + return handleVatsimPilots(interaction, vatsimData, callsignSearch); + } + case 'observers': { + if (!callsignSearch) { + return interaction.editReply({ content: 'You need to provide a valid callsign or part of a callsign to search for.' }); + } + return handleVatsimObservers(interaction, vatsimData, callsignSearch); + } + case 'events': { + return handleVatsimEvents(interaction); + } + default: { + return interaction.editReply({ content: 'Unknown subcommand' }); + } } }); diff --git a/src/commands/utils/wolframAlpha.ts b/src/commands/utils/wolframAlpha.ts index 71ed2744..c0dbc686 100644 --- a/src/commands/utils/wolframAlpha.ts +++ b/src/commands/utils/wolframAlpha.ts @@ -1,5 +1,9 @@ import { ApplicationCommandOptionType, ApplicationCommandType, Colors } from 'discord.js'; -import { slashCommand, slashCommandStructure, makeEmbed, makeLines, Logger } from '../../lib'; +import { z, ZodError } from 'zod'; +import { fetchForeignAPI, Logger, makeEmbed, makeLines, slashCommand, slashCommandStructure, WolframAlphaData, WolframAlphaDataSchema, WolframAlphaPodSchema, WolframAlphaSubpodSchema } from '../../lib'; + +type Pod = z.infer; +type Subpod = z.infer; const data = slashCommandStructure({ name: 'wolframalpha', @@ -19,6 +23,12 @@ const noQueryEmbed = makeEmbed({ color: Colors.Red, }); +const errorEmbed = (error: string) => makeEmbed({ + title: 'Wolfram Alpha Error', + description: error, + color: Colors.Red, +}); + const WOLFRAMALPHA_API_URL = 'https://api.wolframalpha.com/v2/query?'; const WOLFRAMALPHA_QUERY_URL = 'https://www.wolframalpha.com/input/?'; @@ -33,12 +43,12 @@ export default slashCommand(data, async ({ interaction }) => { description: 'Wolfram Alpha token not found.', color: Colors.Red, }); - return interaction.followUp({ embeds: [noTokenEmbed], ephemeral: true }); + return interaction.editReply({ embeds: [noTokenEmbed] }); } const query = interaction.options.getString('query'); - if (!query) return interaction.followUp({ embeds: [noQueryEmbed], ephemeral: true }); + if (!query) return interaction.editReply({ embeds: [noQueryEmbed] }); const params = { appid: wolframAlphaToken, @@ -49,72 +59,62 @@ export default slashCommand(data, async ({ interaction }) => { const searchParams = new URLSearchParams(params); + let response: WolframAlphaData; try { - const response = await fetch(`${WOLFRAMALPHA_API_URL}${searchParams.toString()}`) - .then((res) => res.json()); - - if (response.error) { - const errorEmbed = makeEmbed({ - title: 'Wolfram Alpha Error', - description: response.error, - color: Colors.Red, - }); - return interaction.followUp({ embeds: [errorEmbed], ephemeral: true }); + response = await fetchForeignAPI(`${WOLFRAMALPHA_API_URL}${searchParams.toString()}`, WolframAlphaDataSchema); + } catch (e) { + if (e instanceof ZodError) { + return interaction.editReply({ embeds: [errorEmbed('Wolfram Alpha returned unknown data.')] }); } + Logger.error(`Error while fetching from Wolfram Alpha: ${String(e)}`); + return interaction.editReply({ embeds: [errorEmbed('An error occurred while fetching from Wolfram Alpha.')] }); + } - if (response.queryresult.success === true) { - const podTexts: string[] = []; - response.queryresult.pods.forEach((pod: any) => { - if (pod.id !== 'Input' && pod.primary === true) { - const results: string[] = []; - pod.subpods.forEach((subpod: any) => { - results.push(subpod.plaintext); - }); - if (results.length > 0) { - podTexts.push(`**${pod.title}:** \n${results.join('\n')}`); - } - } - }); - if (podTexts.length > 0) { - const result = podTexts.join('\n\n'); - const queryParams = new URLSearchParams({ i: query }); - - const waEmbed = makeEmbed({ - description: makeLines([ - `**Query:** ${query}`, - '', - result, - '', - `[Web Result](${WOLFRAMALPHA_QUERY_URL}${queryParams.toString()})`, - ]), + if (response.queryresult.success === true) { + const podTexts: string[] = []; + response.queryresult.pods.forEach((pod: Pod) => { + if (pod.id !== 'Input' && pod.primary === true) { + const results: string[] = []; + pod.subpods.forEach((subpod: Subpod) => { + results.push(subpod.plaintext); }); - - return interaction.followUp({ embeds: [waEmbed] }); + if (results.length > 0) { + podTexts.push(`**${pod.title}:** \n${results.join('\n')}`); + } } - const noResultsEmbed = makeEmbed({ - title: 'Wolfram Alpha Error | No Results', + }); + if (podTexts.length > 0) { + const result = podTexts.join('\n\n'); + const queryParams = new URLSearchParams({ i: query }); + + const waEmbed = makeEmbed({ description: makeLines([ - 'No results were found for your query.', + `**Query:** ${query}`, + '', + result, + '', + `[Web Result](${WOLFRAMALPHA_QUERY_URL}${queryParams.toString()})`, ]), - color: Colors.Red, }); - return interaction.followUp({ embeds: [noResultsEmbed], ephemeral: true }); + + return interaction.editReply({ embeds: [waEmbed] }); } - const obscureQueryEmbed = makeEmbed({ - title: 'Wolfram Alpha Error | Could not understand query', + const noResultsEmbed = makeEmbed({ + title: 'Wolfram Alpha Error | No Results', description: makeLines([ - 'Wolfram Alpha could not understand your query.', + 'No results were found for your query.', ]), color: Colors.Red, }); - return interaction.followUp({ embeds: [obscureQueryEmbed], ephemeral: true }); - } catch (e) { - Logger.error('wolframalpha:', e); - const fetchErrorEmbed = makeEmbed({ - title: 'Wolfram Alpha | Fetch Error', - description: 'There was an error fetching your request. Please try again later.', - color: Colors.Red, - }); - return interaction.followUp({ embeds: [fetchErrorEmbed], ephemeral: true }); + return interaction.editReply({ embeds: [noResultsEmbed] }); } + + const obscureQueryEmbed = makeEmbed({ + title: 'Wolfram Alpha Error | Could not understand query', + description: makeLines([ + 'Wolfram Alpha could not understand your query.', + ]), + color: Colors.Red, + }); + return interaction.editReply({ embeds: [obscureQueryEmbed] }); }); diff --git a/src/lib/apis/fetchForeignAPI.ts b/src/lib/apis/fetchForeignAPI.ts new file mode 100644 index 00000000..f6625cf3 --- /dev/null +++ b/src/lib/apis/fetchForeignAPI.ts @@ -0,0 +1,46 @@ +import fetch, { Request, RequestInfo, Response } from 'node-fetch'; +import { ZodSchema } from 'zod'; +import { Logger } from '../logger'; + +/** + * Fetch data from any API endpoint that returns JSON formatted data. + * @typeParam ReturnType - The expected type of the returned data. + * @param request The [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object to be passed to `fetch()`. + * @param zodSchema The [Zod](https://github.com/colinhacks/zod) schema that the returned data conforms to. The promise will reject if the returned data does not conform to the schema provided. + * @returns A promise that resolves to the expected type or rejects with an [Error](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) or a {@link ZodError} if the validation failed. + */ +export const fetchForeignAPI = async (request: RequestInfo, zodSchema: ZodSchema, debug?: boolean): Promise => { + const req = new Request(request); + + let response: Response; + try { + response = await fetch(req); + } catch (e) { + throw new Error(`An error occured while fetching data from ${req.url}: ${String(e)}`); + } + + if (!response.ok) { + throw new Error(`HTTP Error. Status: ${response.status}`); + } + + let data: unknown; + try { + data = await response.json(); + } catch (e) { + throw new Error(`Could not parse JSON. Make sure the endpoint at ${req.url} returns valid JSON. Error: ${String(e)}`); + } + + const result = zodSchema.safeParse(data); + + if (!result.success) { + Logger.error("[zod] Data validation failed! Pass the 'debug' flag to 'fetchForeignAPI()' to dump the retrieved data to the console."); + Logger.error(`Endpoint location: ${req.url}.`); + if (debug) { + Logger.debug('RETRIEVED DATA:', data); + } + result.error.issues.forEach((issue) => Logger.error(`[zod] Code: ${issue.code}, Path: ${issue.path.join('.')}, Message: ${issue.message}`)); + throw result.error; + } + + return result.data; +}; diff --git a/src/lib/apis/zodSchemas/avwx/metarSchemas.ts b/src/lib/apis/zodSchemas/avwx/metarSchemas.ts new file mode 100644 index 00000000..fcf39046 --- /dev/null +++ b/src/lib/apis/zodSchemas/avwx/metarSchemas.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +export const MetarTimeSchema = z.object({ dt: z.string().datetime() }); + +export const MetarWindDirectionSchema = z.object({ repr: z.string() }); + +export const MetarWindSpeedSchema = z.object({ repr: z.string() }); + +export const MetarVisibilitySchema = z.object({ repr: z.string() }); + +export const MetarTemperatureSchema = z.object({ repr: z.string() }); + +export const MetarDewpointSchema = z.object({ repr: z.string() }); + +export const MetarAltimeterSchema = z.object({ value: z.number() }); + +export const MetarUnitsSchema = z.object({ + accumulation: z.string(), + altimeter: z.string(), + altitude: z.string(), + temperature: z.string(), + visibility: z.string(), + wind_speed: z.string(), +}); + +/** + * This schema only contains currently used fields. If you wish to use other fields returned by the API add them in this file. + */ +export const MetarSchema = z.object({ + station: z.string(), + raw: z.string(), + time: MetarTimeSchema, + wind_direction: MetarWindDirectionSchema, + wind_speed: MetarWindSpeedSchema, + visibility: MetarVisibilitySchema, + temperature: MetarTemperatureSchema, + dewpoint: MetarDewpointSchema, + altimeter: MetarAltimeterSchema, + flight_rules: z.string(), + units: MetarUnitsSchema, +}); + +/** + * This type only contains currently used fields. If you wish to use other fields returned by the API add them in this file. + */ +export type Metar = z.infer; diff --git a/src/lib/apis/zodSchemas/avwx/stationSchemas.ts b/src/lib/apis/zodSchemas/avwx/stationSchemas.ts new file mode 100644 index 00000000..7f08f268 --- /dev/null +++ b/src/lib/apis/zodSchemas/avwx/stationSchemas.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const AVWXRunwaySchema = z.object({ + length_ft: z.number(), + width_ft: z.number(), + ident1: z.string(), + ident2: z.string(), +}); + +export const AVWXStationSchema = z.object({ + city: z.string(), + country: z.string(), + elevation_ft: z.number(), + elevation_m: z.number(), + icao: z.string(), + latitude: z.number(), + longitude: z.number(), + name: z.string(), + runways: z.nullable(z.array(AVWXRunwaySchema)), + type: z.string(), + website: z.nullable(z.string().url()), + wiki: z.nullable(z.string().url()), +}); + +export type AVWXStation = z.infer; diff --git a/src/lib/apis/zodSchemas/avwx/tafSchemas.ts b/src/lib/apis/zodSchemas/avwx/tafSchemas.ts new file mode 100644 index 00000000..69356b74 --- /dev/null +++ b/src/lib/apis/zodSchemas/avwx/tafSchemas.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const TafForecastSchema = z.object({ raw: z.string() }); + +/** + * This schema only contains currently used fields. If you wish to use other fields returned by the API add them in this file. + */ +export const TafSchema = z.object({ + raw: z.string(), + station: z.string(), + forecast: z.array(TafForecastSchema), +}); +/** + * This type only contains currently used fields. If you wish to use other fields returned by the API add them in this file. + */ +export type TAF = z.infer; diff --git a/src/lib/apis/zodSchemas/flybywire/telex.ts b/src/lib/apis/zodSchemas/flybywire/telex.ts new file mode 100644 index 00000000..8a670976 --- /dev/null +++ b/src/lib/apis/zodSchemas/flybywire/telex.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const TelexCountSchema = z.number(); + +export type TelexCount = z.infer; diff --git a/src/lib/apis/zodSchemas/simbrief/simbriefSchemas.ts b/src/lib/apis/zodSchemas/simbrief/simbriefSchemas.ts new file mode 100644 index 00000000..3b1b77ea --- /dev/null +++ b/src/lib/apis/zodSchemas/simbrief/simbriefSchemas.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +const SimBriefFetchSchema = z.object({ status: z.string() }); + +const SimBriefParamsSchema = z.object({ + user_id: z.string(), + time_generated: z.string(), + airac: z.string(), +}); + +const SimBriefAircraftSchema = z.object({ + name: z.string(), + internal_id: z.string(), +}); + +const SimBriefOriginSchema = z.object({ + icao_code: z.string(), + plan_rwy: z.string(), +}); + +const SimBriefDestinationSchema = z.object({ + icao_code: z.string(), + plan_rwy: z.string(), +}); + +const SimBriefGeneralSchema = z.object({ route: z.string() }); + +/** + * This schema only contains currently used fields. If you wish to use other fields returned by the API add them in this file. + */ +export const SimbriefFlightPlanSchema = z.object({ + fetch: SimBriefFetchSchema, + params: SimBriefParamsSchema, + aircraft: SimBriefAircraftSchema, + origin: SimBriefOriginSchema, + destination: SimBriefDestinationSchema, + general: SimBriefGeneralSchema, +}); + +/** + * This type only contains currently used fields. If you wish to use other fields returned by the API add them in this file. + */ +export type SimbriefFlightPlan = z.infer; diff --git a/src/lib/apis/zodSchemas/vatsim/vatsimDataSchemas.ts b/src/lib/apis/zodSchemas/vatsim/vatsimDataSchemas.ts new file mode 100644 index 00000000..aaed9092 --- /dev/null +++ b/src/lib/apis/zodSchemas/vatsim/vatsimDataSchemas.ts @@ -0,0 +1,146 @@ +import { z } from 'zod'; + +export const VatsimMilitaryRatingSchema = z.object({ + id: z.number(), + short_name: z.string(), + long_name: z.string(), +}); + +export const VatsimPilotRatingSchema = z.object({ + id: z.number(), + short_name: z.string(), + long_name: z.string(), +}); + +export const VatsimRatingSchema = z.object({ + id: z.number(), + short: z.string(), + long: z.string(), +}); + +export const VatsimFacilitySchema = z.object({ + id: z.number(), + short: z.string(), + long: z.string(), +}); + +export const VatsimFlightPlanSchema = z.object({ + flight_rules: z.enum(['I', 'V']), + aircraft: z.string(), + aircraft_faa: z.string(), + aircraft_short: z.string(), + departure: z.string(), + arrival: z.string(), + alternate: z.string(), + deptime: z.string(), + enroute_time: z.string(), + fuel_time: z.string(), + remarks: z.string(), + route: z.string(), + revision_id: z.number(), + assigned_transponder: z.string(), +}); + +export const VatsimPrefileSchema = z.object({ + cid: z.number(), + name: z.string(), + callsign: z.string(), + flight_plan: VatsimFlightPlanSchema, + last_updated: z.string(), +}); + +export const VatsimServerSchema = z.object({ + ident: z.string(), + hostname_or_ip: z.string(), + location: z.string(), + name: z.string(), + /** + * @deprecated + */ + clients_connection_allowed: z.number(), + client_connections_allowed: z.boolean(), + is_sweatbox: z.boolean(), +}); + +export const VatsimAtisSchema = z.object({ + cid: z.number(), + name: z.string(), + callsign: z.string(), + frequency: z.string(), + facility: z.number(), + rating: z.number(), + server: z.string(), + visual_range: z.number(), + atis_code: z.nullable(z.string()), + text_atis: z.nullable(z.array(z.string())), + last_updated: z.string(), + logon_time: z.string(), +}); + +export const VatsimControllerSchema = z.object({ + cid: z.number(), + name: z.string(), + callsign: z.string(), + facility: z.number(), + frequency: z.string(), + rating: z.number(), + server: z.string(), + visual_range: z.number(), + text_atis: z.nullable(z.array(z.string())), + last_updated: z.string(), + logon_time: z.string(), +}); + +export const VatsimPilotSchema = z.object({ + cid: z.number(), + name: z.string(), + callsign: z.string(), + server: z.string(), + pilot_rating: z.number(), + military_rating: z.number(), + latitude: z.number(), + longitude: z.number(), + altitude: z.number(), + groundspeed: z.number(), + transponder: z.string(), + heading: z.number(), + qnh_i_hg: z.number(), + qnh_mb: z.number(), + flight_plan: z.nullable(VatsimFlightPlanSchema), + logon_time: z.string(), + last_updated: z.string(), +}); + +export const VatsimGeneralSchema = z.object({ + version: z.number(), + /** + * @deprecated + */ + reload: z.number(), + /** + * @deprecated + */ + update: z.string(), + update_timestamp: z.string(), + connected_clients: z.number(), + unique_users: z.number(), +}); + +/** + * Note: The docs do not completely align with actual returned data. The schemas reflect actual returned data structures. + * @see https://vatsim.dev/api/data-api/get-network-data + */ +export const VatsimDataSchema = z.object({ + general: VatsimGeneralSchema, + pilots: z.array(VatsimPilotSchema), + controllers: z.array(VatsimControllerSchema), + atis: z.array(VatsimAtisSchema), + servers: z.array(VatsimServerSchema), + prefiles: z.array(VatsimPrefileSchema), + facilities: z.array(VatsimFacilitySchema), + ratings: z.array(VatsimRatingSchema), + pilot_ratings: z.array(VatsimPilotRatingSchema), + military_ratings: z.array(VatsimMilitaryRatingSchema), +}); + +export type VatsimData = z.infer; diff --git a/src/lib/apis/zodSchemas/vatsim/vatsimEventsSchemas.ts b/src/lib/apis/zodSchemas/vatsim/vatsimEventsSchemas.ts new file mode 100644 index 00000000..d4122445 --- /dev/null +++ b/src/lib/apis/zodSchemas/vatsim/vatsimEventsSchemas.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +export const VatsimOrganiserSchema = z.object({ + region: z.nullable(z.string()), + division: z.nullable(z.string()), + subdivision: z.nullable(z.string()), + organised_by_vatsim: z.boolean(), +}); + +export const VatsimAirportSchema = z.object({ icao: z.string() }); + +export const VatsimRouteSchema = z.object({ + departure: z.string(), + arrival: z.string(), + route: z.string(), +}); + +export const VatsimEventsDataSchema = z.object({ + id: z.number(), + type: z.enum(['Event', 'Controller Examination', 'VASOPS Event']), + name: z.string(), + link: z.string(), + organisers: z.array(VatsimOrganiserSchema), + airports: z.array(VatsimAirportSchema), + routes: z.array(VatsimRouteSchema), + start_time: z.string(), + end_time: z.string(), + short_description: z.string(), + description: z.string(), + banner: z.string(), +}); + +/** + * @see https://vatsim.dev/api/events-api/1.0.0/list-events + */ +export const VatsimEventsSchema = z.object({ data: z.array(VatsimEventsDataSchema) }); + +export type VatsimEvents = z.infer; diff --git a/src/lib/apis/zodSchemas/wolframAlpha/wolframAlphaSchemas.ts b/src/lib/apis/zodSchemas/wolframAlpha/wolframAlphaSchemas.ts new file mode 100644 index 00000000..07d937bb --- /dev/null +++ b/src/lib/apis/zodSchemas/wolframAlpha/wolframAlphaSchemas.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +export const WolframAlphaErrorSchema = z.object({ + code: z.number(), + msg: z.string(), +}); + +export const WolframAlphaSubpodSchema = z.object({ + title: z.string(), + plaintext: z.string({ message: 'Only appears if the requested result formats include plain text.' }), +}); + +export const WolframAlphaPodSchema = z.object({ + title: z.string(), + error: z.union([z.boolean(), WolframAlphaErrorSchema]), + position: z.number(), + scanner: z.string(), + id: z.string(), + numsubpods: z.number(), + primary: z.optional(z.boolean()), + subpods: z.array(WolframAlphaSubpodSchema), +}); + +const BaseQueryResultSchema = z.object({ + error: z.union([z.boolean(), WolframAlphaErrorSchema]), + numpods: z.number(), + version: z.string(), + datatypes: z.string(), + timing: z.number(), + timedout: z.union([z.string(), z.number()]), + parsetiming: z.number(), + parsetimedout: z.boolean(), + recalculate: z.string(), +}); + +const SuccessQueryResultSchema = BaseQueryResultSchema.extend({ + success: z.literal(true), + pods: z.array(WolframAlphaPodSchema), +}); + +const NoSuccessQueryResultSchema = BaseQueryResultSchema.extend({ success: z.literal(false) }); + +export const WolframAlphaQueryResultSchema = z.discriminatedUnion('success', [SuccessQueryResultSchema, NoSuccessQueryResultSchema]); + +export const WolframAlphaDataSchema = z.object({ queryresult: WolframAlphaQueryResultSchema }); + +/** + * This type only includes currently used properties. If you wish to extend its functionality, add the relevant schemas in this file. + */ +export type WolframAlphaData = z.infer; diff --git a/src/lib/index.ts b/src/lib/index.ts index 2f98d658..4bb22deb 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -26,3 +26,14 @@ export * from './schedulerJobs/refreshInMemoryCache'; //Cache Management export * from './cache/cacheManager'; + +// API Wrapper +export * from './apis/fetchForeignAPI'; +export * from './apis/zodSchemas/vatsim/vatsimEventsSchemas'; +export * from './apis/zodSchemas/vatsim/vatsimDataSchemas'; +export * from './apis/zodSchemas/avwx/metarSchemas'; +export * from './apis/zodSchemas/avwx/tafSchemas'; +export * from './apis/zodSchemas/avwx/stationSchemas'; +export * from './apis/zodSchemas/simbrief/simbriefSchemas'; +export * from './apis/zodSchemas/wolframAlpha/wolframAlphaSchemas'; +export * from './apis/zodSchemas/flybywire/telex';