feat: barebones flight-add command with barebones api integrations

This commit is contained in:
2025-07-12 22:19:59 +00:00
parent 9644ba8ab8
commit 1a6d09e0b5
10 changed files with 509 additions and 38 deletions

View File

@@ -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

View File

@@ -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",
},

View File

@@ -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"
}

BIN
src/datasets/airports.db Normal file

Binary file not shown.

View File

@@ -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 :)");

55
src/util/adsbdb.ts Normal file
View File

@@ -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<AdsbdbFlightrouteResponse>();
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;
};
};
};
}

46
src/util/airports.ts Normal file
View File

@@ -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<Airport | null> {
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;
}

115
src/util/dates.ts Normal file
View File

@@ -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

101
src/util/opensky.ts Normal file
View File

@@ -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<AirportData[]>();
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<string> {
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;
}

View File

@@ -1,25 +0,0 @@
import ky from 'ky';
import { cache } from '..';
export async function getOpenskyToken(): Promise<string> {
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;
}