diff --git a/DESIGN.md b/DESIGN.md index 0147319..b57f4b5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -15,7 +15,7 @@ A Slack bot that allows users to track their flights and automatically posts upd ### Commands #### `/flight-add` -**Usage:** `/flight-add [flight_number] [date] [departure_airport] [arrival_airport]` +**Usage:** `/flight-add [airport_code] [DD-MM-YY]` **Description:** Add a new flight to track **Example:** `/flight-add AA1234 2024-01-15 LAX JFK` @@ -35,7 +35,7 @@ A Slack bot that allows users to track their flights and automatically posts upd ## Data Models -### User +### Usere airport coordinate data to determine proxim - `id`: Unique identifier - `slack_user_id`: Slack user ID - `slack_channel_id`: User's personal channel ID diff --git a/bun.lock b/bun.lock index 50feeff..6c71899 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@samocodes/bun-cache": "^1.0.9", "@slack/bolt": "^4.4.0", "chrono-node": "^2.8.3", + "dayjs": "^1.11.13", "keyv": "^5.3.4", "ky": "^1.8.1", }, diff --git a/package.json b/package.json index 4158201..b2b848d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@samocodes/bun-cache": "^1.0.9", "@slack/bolt": "^4.4.0", "chrono-node": "^2.8.3", + "dayjs": "^1.11.13", "keyv": "^5.3.4", "ky": "^1.8.1" } diff --git a/src/datasets/airports.db b/src/datasets/airports.db new file mode 100644 index 0000000..7205e8a Binary files /dev/null and b/src/datasets/airports.db differ diff --git a/src/index.ts b/src/index.ts index c09ddf9..f96dc99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,10 @@ -import { PrismaClient } from "@prisma/client"; -import BunCache from "@samocodes/bun-cache"; -import { App } from "@slack/bolt"; -import ky from "ky"; -import { getOpenskyToken } from "./util/openskyToken"; +import { PrismaClient } from '@prisma/client'; +import BunCache from '@samocodes/bun-cache'; +import { App } from '@slack/bolt'; +import { OpenskyService } from './util/opensky'; +import { parseDate, formatDate } from './util/dates'; +import { Airports } from './util/airports'; +import { AdsBDB } from './util/adsbdb'; const app = new App({ token: process.env.SLACK_BOT_TOKEN, @@ -11,15 +13,190 @@ const app = new App({ }); export const db = new PrismaClient(); +export const airports = new Airports(); export const cache = new BunCache(); +export const openSky = new OpenskyService(); +export const adsbDb = new AdsBDB(); -const now = Math.floor(Date.now() / 1000); -const flights = await ky.get(`https://opensky-network.org/api/flights/departure?airport=LEMG&begin=${now - 3600}&end=${now}`, { - headers: { - 'Authorization': `Bearer ${await getOpenskyToken()}`, +app.command('/flight-add', async ({ command, ack, respond }) => { + await ack(); + + let [airportCode, dateInput] = command.text.split(' '); + if (!airportCode || airportCode.length !== 3) { + await respond({ text: 'Please provide a valid airport code.' }); + return; } -}).json(); -console.log("Fetched flights:", flights); + + let airport = await airports.getAirportByCode(airportCode, 'iata'); + if (!airport) { + airport = await airports.getAirportByCode(airportCode, 'icao'); + } + + if (!airport || !airport.iata_code) { + await respond({ + text: `Airport code "${airportCode}" not found. Please provide a valid IATA or ICAO airport code.`, + }); + return; + } + + airportCode = airport.icao_code.toUpperCase(); + + const { date, error } = parseDate(dateInput); + if (error) { + await respond({ text: `Invalid date format. ${error}` }); + return; + } + + try { + const flights = await openSky.getAirportFlights( + airportCode, + 'departure', + date!.begin, + date!.end + ); + + if (flights.length === 0) { + await respond({ + text: `No flights found for ${airportCode} on ${formatDate(new Date(date!.begin * 1000))}`, + }); + return; + } + + const CONCURRENCY_LIMIT = 20; + const flightOptions = []; + + for (let i = 0; i < Math.min(flights.length, 50); i += CONCURRENCY_LIMIT) { + const batch = flights.slice(i, i + CONCURRENCY_LIMIT); + + const batchResults = await Promise.all( + batch.map(async (flight) => { + if (!flight.callsign) return null; + + const now = performance.now(); + try { + const { departure: hexDeparture, arrival: hexArrival } = await adsbDb + .getRoute(flight.callsign) + .catch(() => ({ departure: undefined, arrival: undefined })); + + if ( + (!hexDeparture || !hexArrival) && + (!flight.estDepartureAirport || !flight.estArrivalAirport) + ) { + return null; + } + + const departure = + hexDeparture || + (await airports.changeAirportCode(flight.estDepartureAirport!, 'icao')); + const arrival = + hexArrival || (await airports.changeAirportCode(flight.estArrivalAirport!, 'icao')); + + if (!departure || !arrival) { + console.warn(`Missing route for flight ${flight.callsign}`); + return null; + } + + console.log( + `Route lookup took ${performance.now() - now}ms for flight ${flight.callsign}` + ); + + return { + text: { + type: 'plain_text' as const, + text: `${flight.callsign?.trim()} - ${departure} → ${arrival}`, + }, + value: JSON.stringify({ + callsign: flight.callsign, + icao24: flight.icao24, + departureAirport: flight.estDepartureAirport, + arrivalAirport: flight.estArrivalAirport, + firstSeen: flight.firstSeen, + lastSeen: flight.lastSeen, + }), + }; + } catch (error) { + console.warn(`Error processing flight ${flight.callsign}:`, error); + return null; + } + }) + ); + + flightOptions.push(...batchResults.filter((option) => option !== null)); + + // Small delay between batches to be nice to the API + if (i + CONCURRENCY_LIMIT < flights.length) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + await respond({ + text: `Found ${flights.length} flights for ${airportCode} on ${formatDate( + new Date(date!.begin * 1000) + )}`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Found ${flights.length} flights for ${airportCode}* on ${formatDate( + new Date(date!.begin * 1000) + )}`, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'Choose a flight:', + }, + accessory: { + type: 'static_select', + placeholder: { + type: 'plain_text', + text: 'Select a flight', + }, + options: flightOptions, + action_id: 'flight_selection', + }, + }, + ], + }); + } catch (error) { + console.error('Error fetching flights:', error); + await respond({ text: 'Error fetching flights. Check the airport code and try again!' }); + } +}); + +app.action('flight_selection', async ({ body, ack, respond }) => { + await ack(); + + try { + // I LOVE TYPESCRIPT + const selectedValue = + 'actions' in body && body.actions?.[0] && 'selected_option' in body.actions[0] + ? body.actions[0].selected_option?.value + : undefined; + + if (!selectedValue) { + await respond({ text: 'No flight selected.' }); + return; + } + + const flightData = JSON.parse(selectedValue); + + // save to database for tracking + + await respond({ + text: `✅ Added flight ${flightData.callsign || 'Unknown'} from ${ + flightData.departureAirport + } to ${flightData.arrivalAirport} to your tracking list!`, + replace_original: true, + }); + } catch (error) { + console.error('Error handling flight selection:', error); + await respond({ text: 'Error adding flight to tracking. Please try again.' }); + } +}); await app.start(); console.log("We're up and running :)"); diff --git a/src/util/adsbdb.ts b/src/util/adsbdb.ts new file mode 100644 index 0000000..7b1602f --- /dev/null +++ b/src/util/adsbdb.ts @@ -0,0 +1,55 @@ +import ky from 'ky'; +import { Airports } from './airports'; + +export class AdsBDB { + public async getRoute(icao24: string) { + const url = `https://api.adsbdb.com/v0/callsign/${icao24.trim()}`; + const response = await ky.get(url).json(); + + return { + flight: response.response.flightroute.callsign, + departure: response.response.flightroute.origin.iata_code, + arrival: response.response.flightroute.destination.iata_code, + }; + } +} + +export interface AdsbdbFlightrouteResponse { + response: { + flightroute: { + callsign: string; + callsign_icao: string; + callsign_iata: string; + airline: { + name: string; + icao: string; + iata: string; + country: string; + country_iso: string; + callsign: string; + }; + origin: { + country_iso_name: string; + country_name: string; + elevation: number; + iata_code: string; + icao_code: string; + latitude: number; + longitude: number; + municipality: string; + name: string; + }; + destination: { + country_iso_name: string; + country_name: string; + elevation: number; + iata_code: string; + icao_code: string; + latitude: number; + longitude: number; + municipality: string; + name: string; + }; + }; + }; +} diff --git a/src/util/airports.ts b/src/util/airports.ts new file mode 100644 index 0000000..3197792 --- /dev/null +++ b/src/util/airports.ts @@ -0,0 +1,46 @@ +import { Database } from 'bun:sqlite'; + +export class Airports { + constructor() { + this.db = new Database('src/datasets/airports.db'); + } + + public db: Database; + + // ICAO: LEMG, IATA: AGP + public async getAirportByCode(code: string, type: 'icao' | 'iata'): Promise { + const query = `SELECT * FROM airports WHERE ${type}_code = ?`; + const result = (await this.db.query(query).get(code.toUpperCase())) as Airport; + return result || null; + } + public async changeAirportCode(code: string, type: 'icao' | 'iata') { + const now = performance.now(); + const res = await this.getAirportByCode(code, type); + if (!res) return null; + + // console.log(`Airport lookup took ${performance.now() - now}ms`); + return type === 'icao' ? res.iata_code : res.icao_code; + } +} + +interface Airport { + id: string; + ident: string; + type: string; + name: string; + latitude_deg: string; + longitude_deg: string; + elevation_ft: string; + continent: string; + iso_country: string; + iso_region: string; + municipality: string; + scheduled_service: string; + icao_code: string; + iata_code: string; + gps_code: string; + local_code: string; + home_link: string; + wikipedia_link: string; + keywords: string; +} diff --git a/src/util/dates.ts b/src/util/dates.ts new file mode 100644 index 0000000..5d78b44 --- /dev/null +++ b/src/util/dates.ts @@ -0,0 +1,115 @@ +export function getRange(date?: Date, days = 1): { begin: number; end: number } { + const startDate = date ? new Date(date) : new Date(); + const begin = Math.floor(startDate.setHours(0, 0, 0, 0) / 1000); + + const endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + days - 1); + const end = Math.floor(endDate.setHours(23, 59, 59, 999) / 1000); + + return { begin, end }; +} + +// AI GENERATED CODE BEGINS HERE +export function parseDate(input?: string): { date: { begin: number; end: number } | null; error?: string } { + // No date provided - use today + if (!input || input.trim() === '') { + return { date: getRange() }; + } + + const cleaned = input.trim().toLowerCase(); + + // Handle relative dates + if (cleaned === 'today') { + return { date: getRange() }; + } + + if (cleaned === 'tomorrow') { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + return { date: getRange(tomorrow) }; + } + + // Try ISO format first (YYYY-MM-DD) + const isoMatch = cleaned.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); + if (isoMatch && isoMatch[1] && isoMatch[2] && isoMatch[3]) { + const [, year, month, day] = isoMatch; + const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); + if (isValidDate(date)) { + return { date: getRange(date) }; + } + } + + // Try DD-MM-YY or DD/MM/YY format (European) + const euroMatch = cleaned.match(/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{2,4})$/); + if (euroMatch && euroMatch[1] && euroMatch[2] && euroMatch[3]) { + const [, day, month, year] = euroMatch; + const fullYear = expandYear(parseInt(year)); + const date = new Date(fullYear, parseInt(month) - 1, parseInt(day)); + if (isValidDate(date)) { + return { date: getRange(date) }; + } + } + + // Try natural language dates + const naturalDate = parseNaturalDate(cleaned); + if (naturalDate) { + return { date: getRange(naturalDate) }; + } + + return { + date: null, + error: "Please use formats like: today, tomorrow, 15-01-25, 2025-01-15, or 15/01/2025" + }; +} + +function isValidDate(date: Date): boolean { + if (isNaN(date.getTime())) return false; + + // Check if date is not too far in the past or future + const now = new Date(); + const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); + const twoYearsFromNow = new Date(now.getFullYear() + 2, now.getMonth(), now.getDate()); + + return date >= oneYearAgo && date <= twoYearsFromNow; +} + +function expandYear(year: number): number { + if (year >= 2000) return year; + if (year >= 50) return 1900 + year; + return 2000 + year; +} + +function parseNaturalDate(input: string): Date | null { + const now = new Date(); + + // Handle "next monday", "this friday", etc. + const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; + + for (let i = 0; i < dayNames.length; i++) { + const dayName = dayNames[i]; + if (dayName && input.includes(dayName)) { + const targetDay = i; + const currentDay = now.getDay(); + + let daysToAdd = targetDay - currentDay; + if (input.includes('next') || daysToAdd <= 0) { + daysToAdd += 7; + } + + const targetDate = new Date(now); + targetDate.setDate(now.getDate() + daysToAdd); + return targetDate; + } + } + + return null; +} + +export function formatDate(date: Date): string { + return date.toLocaleDateString('en-GB', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); +} +// AI GENERATED CODE ENDS HERE diff --git a/src/util/opensky.ts b/src/util/opensky.ts new file mode 100644 index 0000000..e34c123 --- /dev/null +++ b/src/util/opensky.ts @@ -0,0 +1,101 @@ +import ky from 'ky'; +import { cache } from '..'; +import { getRange } from './dates'; + +export class OpenskyService { + public async getAirportFlights(airportIcao: string, type: 'departure' | 'arrival', begin?: number, end?: number) { + const todayDates = getRange(); + begin = begin || todayDates.begin; + end = end || todayDates.end; + + const url = `https://opensky-network.org/api/flights/${type}?airport=${airportIcao}&begin=${begin}&end=${end}`; + const response = await ky.get(url, { + headers: { + 'Authorization': `Bearer ${await getOpenskyToken()}`, + }, + }).json(); + console.log(response) + return response; + } + public async getFlightStatus(flightNumber: string) { + const response = await ky + .get('https://opensky-network.org/api/states/all') + .json<{ states: any[][] | null; time: number }>(); + if (!response.states) { + return { status: 'NO_DATA' }; + } + + // janky fuzzy search because flight numbers SUCK + const flight = response.states.find((state) => { + const callsign = state[1]?.trim().toUpperCase(); + const searchFlight = flightNumber.replace(/\s/g, '').toUpperCase(); + + return ( + callsign === searchFlight || + callsign?.includes(searchFlight) || + searchFlight?.includes(callsign) + ); + }); + if (!flight) { + return { status: 'NOT_FOUND' }; + } + + return { + status: 'OK', + flight: { + icao24: flight[0], + callsign: flight[1], + originCountry: flight[2], + timePosition: flight[3], + lastContact: flight[4], + longitude: flight[5], + latitude: flight[6], + baroAltitude: flight[7], + onGround: flight[8], + velocity: flight[9], + trueTrack: flight[10], + verticalRate: flight[11], + sensors: flight[12], + geoAltitude: flight[13], + }, + }; + } +} + +export async function getOpenskyToken(): Promise { + if (cache.hasKey('openskyToken')) { + return cache.get('openskyToken')?.toString()!; + } + const { access_token: token } = await ky + .post( + 'https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token', + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: process.env.OPENSKY_CLIENT || '', + client_secret: process.env.OPENSKY_SECRET || '', + }), + } + ) + .json<{ access_token: string }>(); + cache.put('openskyToken', token, 30 * 60 * 1000); // 30min cache + return token; +} + +export interface AirportData { + icao24: string; + firstSeen: number; + estDepartureAirport: string | null; + lastSeen: number; + estArrivalAirport: string | null; + callsign: string | null; + estDepartureAirportHorizDistance: number | null; + estDepartureAirportVertDistance: number | null; + estArrivalAirportHorizDistance: number | null; + estArrivalAirportVertDistance: number | null; + departureAirportCandidatesCount: number | null; + arrivalAirportCandidatesCount: number | null; +} \ No newline at end of file diff --git a/src/util/openskyToken.ts b/src/util/openskyToken.ts deleted file mode 100644 index ca8e38b..0000000 --- a/src/util/openskyToken.ts +++ /dev/null @@ -1,25 +0,0 @@ -import ky from 'ky'; -import { cache } from '..'; - -export async function getOpenskyToken(): Promise { - if (cache.hasKey('openskyToken')) { - return cache.get('openskyToken')?.toString()!; - } - const { access_token: token } = await ky - .post( - 'https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token', - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'client_credentials', - client_id: process.env.OPENSKY_CLIENT || '', - client_secret: process.env.OPENSKY_SECRET || '', - }), - } - ) - .json<{ access_token: string }>(); - cache.put('openskyToken', token, 30 * 60 * 1000); // 30min cache - return token; -}