feat: flight updates!!!!

This commit is contained in:
2025-07-15 22:05:18 +00:00
parent ead012942a
commit 65d60e6636
7 changed files with 435 additions and 6 deletions

View File

@@ -0,0 +1,61 @@
/*
Warnings:
- Added the required column `channelId` to the `Flight` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Flight" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"faFlightId" TEXT NOT NULL,
"ident" TEXT NOT NULL,
"identIcao" TEXT NOT NULL,
"identIata" TEXT,
"registration" TEXT,
"aircraftType" TEXT,
"originIcao" TEXT NOT NULL,
"originIata" TEXT NOT NULL,
"originName" TEXT NOT NULL,
"originCity" TEXT NOT NULL,
"destinationIcao" TEXT NOT NULL,
"destinationIata" TEXT NOT NULL,
"destinationName" TEXT NOT NULL,
"destinationCity" TEXT NOT NULL,
"scheduledOut" DATETIME NOT NULL,
"scheduledOff" DATETIME NOT NULL,
"scheduledOn" DATETIME NOT NULL,
"scheduledIn" DATETIME NOT NULL,
"estimatedOut" DATETIME,
"estimatedOff" DATETIME,
"estimatedOn" DATETIME,
"estimatedIn" DATETIME,
"actualOut" DATETIME,
"actualOff" DATETIME,
"actualOn" DATETIME,
"actualIn" DATETIME,
"status" TEXT NOT NULL,
"departureDelay" INTEGER,
"arrivalDelay" INTEGER,
"cancelled" BOOLEAN NOT NULL DEFAULT false,
"diverted" BOOLEAN NOT NULL DEFAULT false,
"progressPercent" INTEGER,
"gateOrigin" TEXT,
"gateDestination" TEXT,
"terminalOrigin" TEXT,
"terminalDestination" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Flight" ("actualIn", "actualOff", "actualOn", "actualOut", "aircraftType", "arrivalDelay", "cancelled", "createdAt", "departureDelay", "destinationCity", "destinationIata", "destinationIcao", "destinationName", "diverted", "estimatedIn", "estimatedOff", "estimatedOn", "estimatedOut", "faFlightId", "gateDestination", "gateOrigin", "id", "ident", "identIata", "identIcao", "originCity", "originIata", "originIcao", "originName", "progressPercent", "registration", "scheduledIn", "scheduledOff", "scheduledOn", "scheduledOut", "status", "terminalDestination", "terminalOrigin", "updatedAt", "userId") SELECT "actualIn", "actualOff", "actualOn", "actualOut", "aircraftType", "arrivalDelay", "cancelled", "createdAt", "departureDelay", "destinationCity", "destinationIata", "destinationIcao", "destinationName", "diverted", "estimatedIn", "estimatedOff", "estimatedOn", "estimatedOut", "faFlightId", "gateDestination", "gateOrigin", "id", "ident", "identIata", "identIcao", "originCity", "originIata", "originIcao", "originName", "progressPercent", "registration", "scheduledIn", "scheduledOff", "scheduledOn", "scheduledOut", "status", "terminalDestination", "terminalOrigin", "updatedAt", "userId" FROM "Flight";
DROP TABLE "Flight";
ALTER TABLE "new_Flight" RENAME TO "Flight";
CREATE UNIQUE INDEX "Flight_faFlightId_key" ON "Flight"("faFlightId");
CREATE INDEX "Flight_userId_idx" ON "Flight"("userId");
CREATE INDEX "Flight_faFlightId_idx" ON "Flight"("faFlightId");
CREATE INDEX "Flight_scheduledOff_idx" ON "Flight"("scheduledOff");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,55 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Flight" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"faFlightId" TEXT NOT NULL,
"ident" TEXT NOT NULL,
"identIcao" TEXT NOT NULL,
"identIata" TEXT,
"registration" TEXT,
"aircraftType" TEXT,
"originIcao" TEXT NOT NULL,
"originIata" TEXT NOT NULL,
"originName" TEXT NOT NULL,
"originCity" TEXT NOT NULL,
"destinationIcao" TEXT NOT NULL,
"destinationIata" TEXT NOT NULL,
"destinationName" TEXT NOT NULL,
"destinationCity" TEXT NOT NULL,
"scheduledOut" DATETIME,
"scheduledOff" DATETIME NOT NULL,
"scheduledOn" DATETIME NOT NULL,
"scheduledIn" DATETIME NOT NULL,
"estimatedOut" DATETIME,
"estimatedOff" DATETIME,
"estimatedOn" DATETIME,
"estimatedIn" DATETIME,
"actualOut" DATETIME,
"actualOff" DATETIME,
"actualOn" DATETIME,
"actualIn" DATETIME,
"status" TEXT NOT NULL,
"departureDelay" INTEGER,
"arrivalDelay" INTEGER,
"cancelled" BOOLEAN NOT NULL DEFAULT false,
"diverted" BOOLEAN NOT NULL DEFAULT false,
"progressPercent" INTEGER,
"gateOrigin" TEXT,
"gateDestination" TEXT,
"terminalOrigin" TEXT,
"terminalDestination" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Flight" ("actualIn", "actualOff", "actualOn", "actualOut", "aircraftType", "arrivalDelay", "cancelled", "channelId", "createdAt", "departureDelay", "destinationCity", "destinationIata", "destinationIcao", "destinationName", "diverted", "estimatedIn", "estimatedOff", "estimatedOn", "estimatedOut", "faFlightId", "gateDestination", "gateOrigin", "id", "ident", "identIata", "identIcao", "originCity", "originIata", "originIcao", "originName", "progressPercent", "registration", "scheduledIn", "scheduledOff", "scheduledOn", "scheduledOut", "status", "terminalDestination", "terminalOrigin", "updatedAt", "userId") SELECT "actualIn", "actualOff", "actualOn", "actualOut", "aircraftType", "arrivalDelay", "cancelled", "channelId", "createdAt", "departureDelay", "destinationCity", "destinationIata", "destinationIcao", "destinationName", "diverted", "estimatedIn", "estimatedOff", "estimatedOn", "estimatedOut", "faFlightId", "gateDestination", "gateOrigin", "id", "ident", "identIata", "identIcao", "originCity", "originIata", "originIcao", "originName", "progressPercent", "registration", "scheduledIn", "scheduledOff", "scheduledOn", "scheduledOut", "status", "terminalDestination", "terminalOrigin", "updatedAt", "userId" FROM "Flight";
DROP TABLE "Flight";
ALTER TABLE "new_Flight" RENAME TO "Flight";
CREATE UNIQUE INDEX "Flight_faFlightId_key" ON "Flight"("faFlightId");
CREATE INDEX "Flight_userId_idx" ON "Flight"("userId");
CREATE INDEX "Flight_faFlightId_idx" ON "Flight"("faFlightId");
CREATE INDEX "Flight_scheduledOff_idx" ON "Flight"("scheduledOff");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,55 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Flight" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"faFlightId" TEXT NOT NULL,
"ident" TEXT NOT NULL,
"identIcao" TEXT NOT NULL,
"identIata" TEXT,
"registration" TEXT,
"aircraftType" TEXT,
"originIcao" TEXT NOT NULL,
"originIata" TEXT NOT NULL,
"originName" TEXT NOT NULL,
"originCity" TEXT NOT NULL,
"destinationIcao" TEXT NOT NULL,
"destinationIata" TEXT NOT NULL,
"destinationName" TEXT NOT NULL,
"destinationCity" TEXT NOT NULL,
"scheduledOut" DATETIME,
"scheduledOff" DATETIME NOT NULL,
"scheduledOn" DATETIME NOT NULL,
"scheduledIn" DATETIME,
"estimatedOut" DATETIME,
"estimatedOff" DATETIME,
"estimatedOn" DATETIME,
"estimatedIn" DATETIME,
"actualOut" DATETIME,
"actualOff" DATETIME,
"actualOn" DATETIME,
"actualIn" DATETIME,
"status" TEXT NOT NULL,
"departureDelay" INTEGER,
"arrivalDelay" INTEGER,
"cancelled" BOOLEAN NOT NULL DEFAULT false,
"diverted" BOOLEAN NOT NULL DEFAULT false,
"progressPercent" INTEGER,
"gateOrigin" TEXT,
"gateDestination" TEXT,
"terminalOrigin" TEXT,
"terminalDestination" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Flight" ("actualIn", "actualOff", "actualOn", "actualOut", "aircraftType", "arrivalDelay", "cancelled", "channelId", "createdAt", "departureDelay", "destinationCity", "destinationIata", "destinationIcao", "destinationName", "diverted", "estimatedIn", "estimatedOff", "estimatedOn", "estimatedOut", "faFlightId", "gateDestination", "gateOrigin", "id", "ident", "identIata", "identIcao", "originCity", "originIata", "originIcao", "originName", "progressPercent", "registration", "scheduledIn", "scheduledOff", "scheduledOn", "scheduledOut", "status", "terminalDestination", "terminalOrigin", "updatedAt", "userId") SELECT "actualIn", "actualOff", "actualOn", "actualOut", "aircraftType", "arrivalDelay", "cancelled", "channelId", "createdAt", "departureDelay", "destinationCity", "destinationIata", "destinationIcao", "destinationName", "diverted", "estimatedIn", "estimatedOff", "estimatedOn", "estimatedOut", "faFlightId", "gateDestination", "gateOrigin", "id", "ident", "identIata", "identIcao", "originCity", "originIata", "originIcao", "originName", "progressPercent", "registration", "scheduledIn", "scheduledOff", "scheduledOn", "scheduledOut", "status", "terminalDestination", "terminalOrigin", "updatedAt", "userId" FROM "Flight";
DROP TABLE "Flight";
ALTER TABLE "new_Flight" RENAME TO "Flight";
CREATE UNIQUE INDEX "Flight_faFlightId_key" ON "Flight"("faFlightId");
CREATE INDEX "Flight_userId_idx" ON "Flight"("userId");
CREATE INDEX "Flight_faFlightId_idx" ON "Flight"("faFlightId");
CREATE INDEX "Flight_scheduledOff_idx" ON "Flight"("scheduledOff");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -13,6 +13,7 @@ datasource db {
model Flight {
id String @id @default(cuid())
userId String
channelId String
// identifiers
faFlightId String @unique // fa_flight_id
@@ -36,10 +37,10 @@ model Flight {
destinationCity String // city name
// timing (stored in iso 8601 format on the api)
scheduledOut DateTime
scheduledOut DateTime?
scheduledOff DateTime // takeoff time
scheduledOn DateTime // landing time
scheduledIn DateTime
scheduledIn DateTime?
estimatedOut DateTime?
estimatedOff DateTime?

View File

@@ -6,8 +6,9 @@ import { FlightAware, type ScheduledDeparture } from './util/flightAware';
import { parseDate, formatDate } from './util/dates';
import { Airports } from './util/airports';
import { AdsBDB } from './util/adsbdb';
import { FlightUpdater } from './util/flightUpdater';
const app = new App({
export const app = new App({
token: process.env.SLACK_BOT_TOKEN,
socketMode: true,
appToken: process.env.SLACK_APP_TOKEN,
@@ -19,11 +20,20 @@ export const cache = new BunCache();
export const openSky = new OpenskyService();
export const flightAware = new FlightAware();
export const adsbDb = new AdsBDB();
export const flightUpdater = new FlightUpdater();
app.command('/flight-add', async ({ command, ack, respond }) => {
const EXAMPLE = `_Example: \`/flight-add AGP today 11\` looks for flights from AGP today at 11:00 (24-hour UTC)._`;
await ack();
if (!command.channel_id || (!command.channel_id.startsWith('C') && !command.channel_id.startsWith('G'))) {
await respond({
text: '❌ This command can only be used in channels.',
response_type: 'ephemeral'
});
return;
}
const parts = command.text.split(' ');
let [airportCode, dateInput, hourInput] = parts;
@@ -206,6 +216,7 @@ app.action('flight_selection', async ({ body, ack, respond }) => {
await db.flight.create({
data: {
userId: body.user.id,
channelId: body.channel?.id!,
faFlightId: flightData.fa_flight_id,
ident: flightData.ident,
identIcao: flightData.ident_icao,
@@ -250,7 +261,7 @@ app.action('flight_selection', async ({ body, ack, respond }) => {
replace_original: true,
});
await app.client.chat.postMessage({
channel: body.user.id,
channel: body.channel?.id!,
text: `Flight \`${callsign}\` is now being tracked inside this channel!`,
});
} catch (error) {
@@ -296,4 +307,5 @@ app.action('flight_page_next', async ({ body, ack, respond }) => {
});
await app.start();
flightUpdater.start();
console.log("We're up and running :)");

View File

@@ -99,10 +99,10 @@ export interface ScheduledDeparture {
departure_delay: number | null;
arrival_delay: number | null;
filed_ete: number;
scheduled_out: string;
scheduled_out?: string;
estimated_out: string | null;
actual_out: string | null;
scheduled_off: string;
scheduled_off?: string;
estimated_off: string | null;
actual_off: string | null;
scheduled_on: string;

245
src/util/flightUpdater.ts Normal file
View File

@@ -0,0 +1,245 @@
import type { Flight } from '@prisma/client';
import { db, flightAware, app } from '../index';
export class FlightUpdater {
private updateInterval: NodeJS.Timeout | null = null;
private flightIntervals: Map<string, NodeJS.Timeout> = new Map();
public start() {
if (this.updateInterval) return;
console.log('flight updater starting');
this.updateInterval = setInterval(() => this.manageFlightPolling(), 10 * 60 * 1000);
this.manageFlightPolling();
}
public stop() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
this.flightIntervals.forEach(interval => clearInterval(interval));
this.flightIntervals.clear();
}
private async manageFlightPolling() {
try {
const flights = await db.flight.findMany({
where: {
actualIn: null,
cancelled: false,
scheduledOff: {
gte: new Date(Date.now() - 4 * 60 * 60 * 1000),
lte: new Date(Date.now() + 24 * 60 * 60 * 1000),
}
}
});
const currentlyTracked = new Set(this.flightIntervals.keys());
const activeFlightIds = new Set(flights.map(f => f.id));
// remove intervals for flights no longer active
for (const flightId of currentlyTracked) {
if (!activeFlightIds.has(flightId)) {
clearInterval(this.flightIntervals.get(flightId)!);
this.flightIntervals.delete(flightId);
console.log(`Stopped tracking flight ${flightId}`);
}
}
// add intervals for new flights
for (const flight of flights) {
if (!this.flightIntervals.has(flight.id)) {
this.setupFlightPolling(flight);
}
}
console.log(`Managing ${this.flightIntervals.size} flight polling intervals`);
} catch (error) {
console.error('Error managing flight polling:', error);
}
}
private setupFlightPolling(flight: Flight) {
const pollInterval = this.calculatePollInterval(flight);
console.log(`Setting up polling for flight ${flight.ident} every ${pollInterval / 60000} minutes`);
// initial update
this.updateSingleFlight(flight);
// set up interval
const interval = setInterval(() => this.updateSingleFlight(flight), pollInterval);
this.flightIntervals.set(flight.id, interval);
}
private calculatePollInterval(flight: Flight): number {
const now = new Date();
const scheduledOff = new Date(flight.scheduledOff);
const scheduledOn = new Date(flight.scheduledOn);
// pre-departure phase (more than 2 hours before takeoff)
if (now < new Date(scheduledOff.getTime() - 2 * 60 * 60 * 1000)) {
return 30 * 60 * 1000; // 30 minutes
}
// pre-departure phase (less than 2 hours before takeoff)
if (now < scheduledOff) {
return 5 * 60 * 1000; // 5 minutes
}
// In-flight phase
if (!flight.actualOn && now < new Date(scheduledOn.getTime() + 2 * 60 * 60 * 1000)) {
// frequent updates during takeoff and initial 5 minute climb
if (now < new Date(scheduledOff.getTime() + 5 * 60 * 1000)) {
return 2 * 60 * 1000; // 2 minutes
}
// Medium frequency during cruise
const flightDuration = scheduledOn.getTime() - scheduledOff.getTime();
const timeInFlight = now.getTime() - scheduledOff.getTime();
const progressPercent = (timeInFlight / flightDuration) * 100;
// frequent updates during landing
if (progressPercent > 80) {
return 3 * 60 * 1000; // 3 minutes
}
return 8 * 60 * 1000; // 8 minutes during cruise
}
// flight arrived
return 15 * 60 * 1000;
}
private async updateSingleFlight(flight: Flight) {
console.log(`updating flight ${flight.ident} (${flight.id})`);
try {
if (flight.actualIn) {
this.removeFlightPolling(flight.id);
return;
}
const flightInfo = await flightAware.getFlightInfo(flight.faFlightId);
const newData = flightInfo.flights[0];
if (!newData) return;
const changes = [];
if (flight.status !== newData.status) {
changes.push(`Status: ${newData.status}`);
}
if (Math.abs((flight.departureDelay || 0) - (newData.departure_delay || 0)) > 10) {
changes.push(`Departure delay: ${newData.departure_delay || 0} minutes`);
}
if (flight.gateOrigin !== newData.gate_origin && newData.gate_origin) {
changes.push(`Gate changed to: ${newData.gate_origin}`);
}
if (!flight.actualOff && newData.actual_off) {
changes.push('✈️ Flight has taken off!');
this.adjustFlightPolling(flight.id, flight);
}
if (!flight.actualOn && newData.actual_on) {
changes.push('🛬 Flight has landed!');
this.removeFlightPolling(flight.id);
}
if (!flight.cancelled && newData.cancelled) {
changes.push('❌ Flight cancelled');
this.removeFlightPolling(flight.id);
}
if (!flight.diverted && newData.diverted) {
changes.push(`🚨 Flight diverted to ${newData.destination.code_iata} (${newData.destination.name})`);
}
// smart progress updates (0 - 10, 10 - 20, etc.)
if (flight.progressPercent !== null && newData.progress_percent !== null && newData.actual_off && !newData.actual_on) {
const oldTens = Math.floor((flight.progressPercent || 0) / 10);
const newTens = Math.floor(newData.progress_percent / 10);
if (oldTens !== newTens && newData.progress_percent !== 100) {
changes.push(`🔄 Flight progress: ${newData.progress_percent}%\n${this.slackProgressbar(newData.progress_percent)}`);
}
}
const updatedFlight = await db.flight.update({
where: { id: flight.id },
data: {
status: newData.status,
departureDelay: newData.departure_delay,
arrivalDelay: newData.arrival_delay,
actualOut: newData.actual_out,
actualOff: newData.actual_off,
actualOn: newData.actual_on,
actualIn: newData.actual_in,
cancelled: newData.cancelled,
diverted: newData.diverted,
gateOrigin: newData.gate_origin,
progressPercent: newData.progress_percent,
}
});
// stop polling on completion
if (updatedFlight.actualIn) {
this.removeFlightPolling(flight.id);
}
if (changes.length > 0) {
const message = `🔔 *${flight.ident}* (${flight.originIata}${flight.destinationIata})\n${changes.join('\n')}`;
await app.client.chat.postMessage({
channel: flight.channelId,
text: message
});
}
} catch (error) {
console.error(`error updating flight ${flight.ident}:`, error);
// adjust polling for error
this.adjustFlightPolling(flight.id, flight, true);
}
}
private adjustFlightPolling(flightId: string, flight: Flight, isError: boolean = false) {
// clear current interval
const existingInterval = this.flightIntervals.get(flightId);
if (existingInterval) {
clearInterval(existingInterval);
}
// calculate interval
let newInterval = this.calculatePollInterval(flight);
// increase interval in case of errors
if (isError) {
newInterval *= 2;
}
// update interval
const interval = setInterval(() => this.updateSingleFlight(flight), newInterval);
this.flightIntervals.set(flightId, interval);
}
private removeFlightPolling(flightId: string) {
const interval = this.flightIntervals.get(flightId);
if (interval) {
clearInterval(interval);
this.flightIntervals.delete(flightId);
console.log(`Stopped polling completed flight ${flightId}`);
}
}
private slackProgressbar(percent: number): string {
const filled = Math.round(percent / 10);
const empty = 10 - filled;
return `\`${'█'.repeat(filled)}${'░'.repeat(empty)}\` ${percent}%`;
}
}