mirror of
https://github.com/SrIzan10/flight-slack.git
synced 2026-06-06 00:56:52 +00:00
feat: barebones flight-add command with barebones api integrations
This commit is contained in:
@@ -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
|
||||
|
||||
1
bun.lock
1
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",
|
||||
},
|
||||
|
||||
@@ -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
BIN
src/datasets/airports.db
Normal file
Binary file not shown.
199
src/index.ts
199
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 :)");
|
||||
|
||||
55
src/util/adsbdb.ts
Normal file
55
src/util/adsbdb.ts
Normal 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
46
src/util/airports.ts
Normal 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
115
src/util/dates.ts
Normal 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
101
src/util/opensky.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user