├── .prettierignore ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .dockerignore ├── src ├── model │ ├── framework │ │ ├── DI │ │ │ └── Beans.ts │ │ ├── factory │ │ │ ├── IDiFactory.ts │ │ │ ├── AbstractFactory.ts │ │ │ └── impl │ │ │ │ └── PropertyResolutionFactory.ts │ │ ├── engine │ │ │ ├── impl │ │ │ │ ├── EnvPropertyResolutionEngine.ts │ │ │ │ └── AbstractRequestEngine.ts │ │ │ └── IPropertyResolutionEngine.ts │ │ ├── manager │ │ │ ├── IvaoManager.ts │ │ │ ├── PosconManager.ts │ │ │ ├── VatsimManager.ts │ │ │ ├── PropertyResolutionManager.ts │ │ │ ├── GeonamesManager.ts │ │ │ ├── AirportDataManager.ts │ │ │ ├── AviationStackManager.ts │ │ │ ├── AeroDataBoxManager.ts │ │ │ ├── AirportManager.ts │ │ │ ├── NatsManager.ts │ │ │ ├── AbstractCallSignInformationManager.ts │ │ │ ├── Av8Manager.ts │ │ │ ├── OpenSkyManager.ts │ │ │ └── AvwxManager.ts │ │ ├── ISearchBase.ts │ │ └── decorators │ │ │ ├── PostConstruct.ts │ │ │ ├── Property.ts │ │ │ └── RunEvery.ts │ ├── logic │ │ └── AutoCompleteHealthChecker.ts │ └── db │ │ └── Mongo.ts ├── enums │ ├── METHOD_EXECUTOR_TIME_UNIT.ts │ └── TIME_UNIT.ts ├── guards │ ├── GuildOnly.ts │ ├── PremiumGuild.ts │ └── RequiredBotPerms.ts ├── api │ ├── middlewares │ │ └── AuthMiddleware.ts │ ├── controllers │ │ ├── BaseController.ts │ │ └── impl │ │ │ └── bot │ │ │ ├── BotController.ts │ │ │ └── shard │ │ │ └── ShardController.ts │ ├── service │ │ ├── BotInfoService.ts │ │ └── ShardInfoService.ts │ └── BotServer.ts ├── commands │ ├── Ping.ts │ ├── Info.ts │ ├── Metar.ts │ ├── Notam.ts │ ├── Help.ts │ ├── Nats.ts │ ├── Taf.ts │ ├── Time.ts │ ├── Station.ts │ ├── Flight.ts │ ├── Vatsim.ts │ ├── Ivao.ts │ ├── Atis.ts │ └── Poscon.ts ├── utils │ ├── LoggerFactory.ts │ └── Utils.ts ├── Bot.ts ├── events │ ├── GuildCreate.ts │ ├── OnShard.ts │ └── OnReady.ts └── main.ts ├── docker-compose.yml ├── .eslintignore ├── .prettierrc.json ├── .deepsource.toml ├── tsconfig.json ├── Dockerfile ├── .circleci └── config.yml ├── .all-contributorsrc ├── .gitignore ├── CONTRIBUTING.md ├── .eslintrc.json ├── package.json ├── CODE_OF_CONDUCT.md └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | LICENSE 3 | build -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: drph4nt0m 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .devcontainer 2 | .dockerignore 3 | npm-debug.log 4 | Dockerfile 5 | node_modules 6 | **/tmp/* 7 | .openode -------------------------------------------------------------------------------- /src/model/framework/DI/Beans.ts: -------------------------------------------------------------------------------- 1 | export class Beans { 2 | public static IPropertyResolutionEngine = Symbol("IPropertyResolutionEngine"); 3 | } 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | app: 4 | build: . 5 | command: node build/main.js 6 | environment: 7 | - BOT_TOKEN=${BOT_TOKEN} 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # don't ever lint node_modules 2 | node_modules 3 | # don't lint build output (make sure it's set to your correct build folder name) 4 | build 5 | # don't lint nyc coverage output 6 | coverage -------------------------------------------------------------------------------- /src/enums/METHOD_EXECUTOR_TIME_UNIT.ts: -------------------------------------------------------------------------------- 1 | enum METHOD_EXECUTOR_TIME_UNIT { 2 | days = "days", 3 | hours = "hours", 4 | minutes = "minutes", 5 | seconds = "seconds" 6 | } 7 | 8 | export default METHOD_EXECUTOR_TIME_UNIT; 9 | -------------------------------------------------------------------------------- /src/enums/TIME_UNIT.ts: -------------------------------------------------------------------------------- 1 | enum TIME_UNIT { 2 | milliseconds, 3 | seconds, 4 | minutes, 5 | hours, 6 | days, 7 | weeks, 8 | months, 9 | years, 10 | decades 11 | } 12 | 13 | export default TIME_UNIT; 14 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "singleQuote": false, 6 | "quoteProps": "consistent", 7 | "bracketSpacing": true, 8 | "arrowParens": "always", 9 | "endOfLine": "auto", 10 | "printWidth": 200, 11 | "proseWrap": "never" 12 | } 13 | -------------------------------------------------------------------------------- /src/model/framework/factory/IDiFactory.ts: -------------------------------------------------------------------------------- 1 | import type * as Immutable from "immutable"; 2 | 3 | /** 4 | * Any factory annotated with @registry must implement this interface to facilitate the resolution of engines 5 | */ 6 | export interface IDiFactory { 7 | /** 8 | * Get an immutable set of all engines this abstract factory can produce 9 | */ 10 | get engines(): Immutable.Set; 11 | } 12 | -------------------------------------------------------------------------------- /src/model/framework/engine/impl/EnvPropertyResolutionEngine.ts: -------------------------------------------------------------------------------- 1 | import { singleton } from "tsyringe"; 2 | 3 | import type { IPropertyResolutionEngine, PropertyTYpe } from "../IPropertyResolutionEngine"; 4 | 5 | @singleton() 6 | export class EnvPropertyResolutionEngine implements IPropertyResolutionEngine { 7 | public getProperty(prop: string): PropertyTYpe { 8 | return process.env[prop] ?? null; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/guards/GuildOnly.ts: -------------------------------------------------------------------------------- 1 | import type { CommandInteraction } from "discord.js"; 2 | import type { Client, Next } from "discordx"; 3 | 4 | /** 5 | * Prevent the command from running on DM 6 | * @param {CommandInteraction} arg 7 | * @param {Client} client 8 | * @param {Next} next 9 | * @constructor 10 | */ 11 | export function GuildOnly(arg: CommandInteraction, client: Client, next: Next): Promise { 12 | if (arg.inGuild()) { 13 | return next(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/model/framework/engine/IPropertyResolutionEngine.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * implementing this interface will grant the ability to get system and application props from different sources, format agnostic 3 | */ 4 | export interface IPropertyResolutionEngine { 5 | /** 6 | * Given a key (prop) return the value of this prop as a string, number or an object 7 | */ 8 | getProperty(prop: string): PropertyTYpe; 9 | } 10 | 11 | export type PropertyTYpe = string | number | Record | null; 12 | -------------------------------------------------------------------------------- /src/model/framework/manager/IvaoManager.ts: -------------------------------------------------------------------------------- 1 | import { singleton } from "tsyringe"; 2 | 3 | import type { IvaoInfo } from "../../Typeings.js"; 4 | import { autoCompleteBaseUrl } from "../ISearchBase.js"; 5 | import { AbstractCallSignInformationManager } from "./AbstractCallSignInformationManager.js"; 6 | 7 | @singleton() 8 | export class IvaoManager extends AbstractCallSignInformationManager { 9 | public constructor() { 10 | super(`${autoCompleteBaseUrl}/flightSimNetwork/ivao`); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/model/framework/manager/PosconManager.ts: -------------------------------------------------------------------------------- 1 | import { singleton } from "tsyringe"; 2 | 3 | import type { PosconInfo } from "../../Typeings.js"; 4 | import { autoCompleteBaseUrl } from "../ISearchBase.js"; 5 | import { AbstractCallSignInformationManager } from "./AbstractCallSignInformationManager.js"; 6 | 7 | @singleton() 8 | export class PosconManager extends AbstractCallSignInformationManager { 9 | public constructor() { 10 | super(`${autoCompleteBaseUrl}/flightSimNetwork/poscon`); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/model/framework/manager/VatsimManager.ts: -------------------------------------------------------------------------------- 1 | import { singleton } from "tsyringe"; 2 | 3 | import type { VatsimInfo } from "../../Typeings.js"; 4 | import { autoCompleteBaseUrl } from "../ISearchBase.js"; 5 | import { AbstractCallSignInformationManager } from "./AbstractCallSignInformationManager.js"; 6 | 7 | @singleton() 8 | export class VatsimManager extends AbstractCallSignInformationManager { 9 | public constructor() { 10 | super(`${autoCompleteBaseUrl}/flightSimNetwork/vatsim`); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/model/framework/ISearchBase.ts: -------------------------------------------------------------------------------- 1 | import type { AutocompleteInteraction } from "discord.js"; 2 | 3 | export type SearchBase = { 4 | name: string; 5 | value: string; 6 | }; 7 | 8 | export interface ISearchBase { 9 | /** 10 | * Preform a search on the search microservice 11 | * @param interaction 12 | */ 13 | search(interaction: AutocompleteInteraction): Promise; 14 | } 15 | 16 | export const autoCompleteAppUrl = "http://localhost:8083"; 17 | export const autoCompleteBaseUrl = `${autoCompleteAppUrl}/rest`; 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Expected Behavior 2 | 3 | Please describe the behavior you are expecting 4 | 5 | ## Current Behavior 6 | 7 | What is the current behavior? 8 | 9 | ## Failure Information (for bugs) 10 | 11 | Please help provide information about the failure if this is a bug. If it is not a bug, please remove the rest of this template. 12 | 13 | ## Steps to Reproduce 14 | 15 | Please provide detailed steps for reproducing the issue. 16 | 17 | 1. step 1 18 | 2. step 2 19 | 3. you get it... 20 | 21 | ## Failure Logs 22 | 23 | Please include any relevant log snippets or files here. 24 | -------------------------------------------------------------------------------- /src/model/framework/factory/AbstractFactory.ts: -------------------------------------------------------------------------------- 1 | import Immutable from "immutable"; 2 | 3 | import type { IDiFactory } from "./IDiFactory.js"; 4 | 5 | export abstract class AbstractFactory implements IDiFactory { 6 | public static readonly factories: AbstractFactory[] = []; 7 | private readonly _engines: Immutable.Set; 8 | 9 | protected constructor(engines: T[]) { 10 | this._engines = Immutable.Set(engines); 11 | AbstractFactory.factories.push(this); 12 | } 13 | 14 | public get engines(): Immutable.Set { 15 | return this._engines; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | exclude_patterns = [ 4 | "public/**,", 5 | "build/**", 6 | "node_modules/**" 7 | ] 8 | 9 | [[analyzers]] 10 | name = "docker" 11 | enabled = true 12 | 13 | [[analyzers]] 14 | name = "secrets" 15 | enabled = true 16 | 17 | [[analyzers]] 18 | name = "javascript" 19 | enabled = true 20 | 21 | [analyzers.meta] 22 | module_system = "es-modules" 23 | environment = [ 24 | "nodejs", 25 | ] 26 | dialect = "typescript" 27 | skip_doc_coverage = ["class-expression", "method-definition"] 28 | 29 | [[transformers]] 30 | name = "prettier" 31 | enabled = true -------------------------------------------------------------------------------- /src/api/middlewares/AuthMiddleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from "express"; 2 | 3 | import { Property } from "../../model/framework/decorators/Property.js"; 4 | 5 | export class AuthMiddleware { 6 | @Property("API_ADMIN_TOKEN") 7 | private static adminToken: string; 8 | 9 | public static isAdmin(req: Partial, res: Partial, next: NextFunction): void { 10 | const token = req.headers?.["authorization"]; 11 | if (token === AuthMiddleware.adminToken) { 12 | next(); 13 | } else { 14 | res.status?.(401).send({ code: 401, error: "Invalid Authorization Token" }); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/api/controllers/BaseController.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from "express"; 2 | import { getReasonPhrase, StatusCodes } from "http-status-codes"; 3 | 4 | export abstract class BaseController { 5 | protected doError(res: Response, message: string, status: StatusCodes): Response { 6 | return res.status(status).json({ 7 | error: `${status} ${getReasonPhrase(status)}`, 8 | message: message 9 | }); 10 | } 11 | 12 | protected ok(res: Response, json: any): Response { 13 | const serialisedJson: string = JSON.stringify(json); 14 | res.setHeader("Content-Type", "application/json"); 15 | return res.status(StatusCodes.OK).send(serialisedJson); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "module": "ESNext", 5 | "outDir": "build", 6 | "rootDir": "src", 7 | "moduleResolution": "Node", 8 | "sourceMap": true, 9 | "noImplicitAny": false, 10 | "allowSyntheticDefaultImports": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "noImplicitOverride": true, 14 | "importsNotUsedAsValues": "error", 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["node_modules", "./build"], 20 | "indent": [true, "spaces", 2] 21 | } 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ## build runner 2 | FROM node:lts-alpine as build-runner 3 | 4 | # Set temp directory 5 | WORKDIR /tmp/app 6 | 7 | # Move package.json 8 | COPY package.json . 9 | 10 | # Install dependencies 11 | RUN npm install 12 | 13 | # Move source files 14 | COPY src ./src 15 | COPY tsconfig.json . 16 | 17 | # Build project 18 | RUN npm run build 19 | 20 | ## producation runner 21 | FROM node:lts-alpine as prod-runner 22 | 23 | # Set work directory 24 | WORKDIR /app 25 | 26 | # Copy package.json from build-runner 27 | COPY --from=build-runner /tmp/app/package.json /app/package.json 28 | 29 | # Install dependencies 30 | RUN npm install --only=production 31 | 32 | # Move build files 33 | COPY --from=build-runner /tmp/app/build /app/build 34 | 35 | # Start bot 36 | CMD [ "node", "build/main.js" ] 37 | -------------------------------------------------------------------------------- /src/model/framework/factory/impl/PropertyResolutionFactory.ts: -------------------------------------------------------------------------------- 1 | import { injectAll, registry, singleton } from "tsyringe"; 2 | 3 | import { Beans } from "../../DI/Beans.js"; 4 | import { EnvPropertyResolutionEngine } from "../../engine/impl/EnvPropertyResolutionEngine.js"; 5 | import type { IPropertyResolutionEngine } from "../../engine/IPropertyResolutionEngine.js"; 6 | import { AbstractFactory } from "../AbstractFactory.js"; 7 | 8 | @registry([ 9 | { 10 | token: Beans.IPropertyResolutionEngine, 11 | useToken: EnvPropertyResolutionEngine 12 | } 13 | ]) 14 | @singleton() 15 | export class PropertyResolutionFactory extends AbstractFactory { 16 | public constructor(@injectAll(Beans.IPropertyResolutionEngine) beans: IPropertyResolutionEngine[]) { 17 | super(beans); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/model/framework/decorators/PostConstruct.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "discordx"; 2 | import { container } from "tsyringe"; 3 | 4 | /** 5 | * Spring-like post construction executor, this will fire after a dependency is resolved and constructed 6 | * @param target 7 | * @param propertyKey 8 | * @param descriptor 9 | * @constructor 10 | */ 11 | export function PostConstruct(target: unknown, propertyKey: string, descriptor: PropertyDescriptor): void { 12 | container.afterResolution( 13 | target.constructor as never, 14 | (_t, result) => { 15 | let client: Client; 16 | if (container.isRegistered(Client)) { 17 | client = container.resolve(Client); 18 | } 19 | descriptor.value.call(result, client); 20 | }, 21 | { 22 | frequency: "Once" 23 | } 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | Fixes # (issue) 6 | 7 | ## How Has This Been Tested 8 | 9 | Please describe the tests that you ran to verify your changes. Please also note any relevant details for your test configuration. 10 | 11 | - [ ] Test A 12 | - [ ] Test B 13 | 14 | ## Checklist 15 | 16 | - [ ] My code follows the style guidelines of this project 17 | - [ ] I have performed a self-review of my own code 18 | - [ ] I have commented my code, particularly in hard-to-understand areas 19 | - [ ] I have made corresponding changes to the documentation/readme 20 | - [ ] My changes generate no new warnings 21 | - [ ] Any dependent changes have been merged and published in downstream modules 22 | -------------------------------------------------------------------------------- /src/commands/Ping.ts: -------------------------------------------------------------------------------- 1 | import { Category } from "@discordx/utilities"; 2 | import { oneLine } from "common-tags"; 3 | import { CommandInteraction, Message } from "discord.js"; 4 | import { Client, Discord, Slash } from "discordx"; 5 | 6 | @Discord() 7 | @Category("Utility") 8 | export class Ping { 9 | @Slash({ 10 | description: "Checks the AvBot's ping to the Discord server" 11 | }) 12 | public async ping(interaction: CommandInteraction, client: Client): Promise { 13 | const msg = (await interaction.reply({ content: "Pinging...", fetchReply: true })) as Message; 14 | const content = oneLine` 15 | ${msg.inGuild() ? `${interaction.member},` : ""} 16 | Pong! The message round-trip took 17 | ${msg.createdTimestamp - interaction.createdTimestamp}ms. 18 | ${client.ws.ping ? `The heartbeat ping is ${Math.round(client.ws.ping)}ms.` : ""} 19 | `; 20 | await msg.edit(content); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | jobs: 4 | build: 5 | docker: 6 | - image: cimg/node:16.17.0 7 | steps: 8 | - checkout 9 | - restore_cache: 10 | keys: 11 | - node-deps-v1-{{ .Branch }}-{{checksum "package-lock.json"}} 12 | - run: 13 | name: install packages 14 | command: npm ci 15 | - save_cache: 16 | key: node-deps-v1-{{ .Branch }}-{{checksum "package-lock.json"}} 17 | paths: 18 | - ~/.npm 19 | - run: 20 | name: Run Build 21 | command: npm run build 22 | deploy-prod: 23 | docker: 24 | - image: arvindr226/alpine-ssh 25 | steps: 26 | - checkout 27 | - run: ssh -oStrictHostKeyChecking=no -v $USER@$IP "./deploy_bot.sh" 28 | 29 | workflows: 30 | deploy: 31 | jobs: 32 | - build 33 | - deploy-prod: 34 | filters: 35 | branches: 36 | only: 37 | - main 38 | requires: 39 | - build 40 | -------------------------------------------------------------------------------- /src/api/controllers/impl/bot/BotController.ts: -------------------------------------------------------------------------------- 1 | import { ChildControllers, Controller, Get } from "@overnightjs/core"; 2 | import type { Request, Response } from "express"; 3 | import { StatusCodes } from "http-status-codes"; 4 | import { singleton } from "tsyringe"; 5 | 6 | import { BotInfoService } from "../../../service/BotInfoService.js"; 7 | import { BaseController } from "../../BaseController.js"; 8 | import { ShardController } from "./shard/ShardController.js"; 9 | 10 | @singleton() 11 | @Controller("api/bot") 12 | @ChildControllers([new ShardController()]) 13 | export class BotController extends BaseController { 14 | public constructor(private _botInfoService: BotInfoService) { 15 | super(); 16 | } 17 | 18 | @Get("info") 19 | private async info(req: Request, res: Response): Promise { 20 | try { 21 | const info = await this._botInfoService.getBotInfo(req.query.top as string); 22 | return super.ok(res, info); 23 | } catch (e) { 24 | return super.doError(res, e.message, StatusCodes.BAD_REQUEST); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/model/framework/manager/PropertyResolutionManager.ts: -------------------------------------------------------------------------------- 1 | import { singleton } from "tsyringe"; 2 | 3 | import type { PropertyTYpe } from "../engine/IPropertyResolutionEngine.js"; 4 | import { PropertyResolutionFactory } from "../factory/impl/PropertyResolutionFactory.js"; 5 | 6 | @singleton() 7 | /** 8 | * Manager to obtain property from the PropertyResolutionFactory 9 | */ 10 | export class PropertyResolutionManager { 11 | public constructor(private _propertyResolutionFactory: PropertyResolutionFactory) {} 12 | 13 | /** 14 | * Get system property 15 | * @param {string} prop 16 | * @returns {PropertyTYpe} 17 | */ 18 | public getProperty(prop: string): PropertyTYpe { 19 | let propValue: PropertyTYpe = null; 20 | for (const resolutionEngine of this._propertyResolutionFactory.engines) { 21 | const resolvedProp = resolutionEngine.getProperty(prop); 22 | if (resolvedProp !== null) { 23 | propValue = resolvedProp ?? null; 24 | break; 25 | } 26 | } 27 | return propValue ?? null; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/model/framework/manager/GeonamesManager.ts: -------------------------------------------------------------------------------- 1 | import { singleton } from "tsyringe"; 2 | 3 | import logger from "../../../utils/LoggerFactory.js"; 4 | import type { GeonamesTimeZone } from "../../Typeings.js"; 5 | import { Property } from "../decorators/Property.js"; 6 | import { AbstractRequestEngine } from "../engine/impl/AbstractRequestEngine.js"; 7 | 8 | @singleton() 9 | export class GeonamesManager extends AbstractRequestEngine { 10 | @Property("GEONAMES_USERNAME") 11 | private readonly _username: string; 12 | 13 | public constructor() { 14 | super("http://api.geonames.org/"); 15 | } 16 | 17 | public async getTimezone(lat: string, long: string): Promise { 18 | try { 19 | const response = await this.api.get(`/timezoneJSON?formatted=true&username=${this._username}&lat=${lat}&lng=${long}&style=full`); 20 | 21 | if (response.status !== 200) { 22 | return Promise.reject(new Error(`cannot retrieve timezone information`)); 23 | } 24 | 25 | return response.data; 26 | } catch (error) { 27 | logger.error(`[x] ${error}`); 28 | return Promise.reject(new Error(error.message || `internal server error`)); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/model/framework/manager/AirportDataManager.ts: -------------------------------------------------------------------------------- 1 | import { singleton } from "tsyringe"; 2 | 3 | import logger from "../../../utils/LoggerFactory.js"; 4 | import type { AircraftInfo } from "../../Typeings.js"; 5 | import { AbstractRequestEngine } from "../engine/impl/AbstractRequestEngine.js"; 6 | 7 | @singleton() 8 | export class AirportDataManager extends AbstractRequestEngine { 9 | public constructor() { 10 | super("https://www.airport-data.com/api/ac_thumb.json", { 11 | params: { 12 | n: "N" 13 | } 14 | }); 15 | } 16 | 17 | public async getAircraftImage(icao24: string): Promise { 18 | try { 19 | const { data } = await this.api.get(null, { 20 | params: { 21 | m: icao24 22 | } 23 | }); 24 | 25 | if (data.data.length > 0) { 26 | return data.data[Math.floor(Math.random() * data.data.length)]; 27 | } 28 | return Promise.reject(new Error(`no aircraft available at the moment with icao24 ${icao24}`)); 29 | } catch (error) { 30 | logger.error(`[x] ${error}`); 31 | return Promise.reject(new Error(`no aircraft available at the moment with icao24 ${icao24}`)); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/model/framework/decorators/Property.ts: -------------------------------------------------------------------------------- 1 | import { container } from "tsyringe"; 2 | 3 | import type { propTypes } from "../../Typeings.js"; 4 | import { PropertyResolutionManager } from "../manager/PropertyResolutionManager.js"; 5 | 6 | const manager = container.resolve(PropertyResolutionManager); 7 | 8 | /** 9 | * Get a property from the system. The location where the property is loaded from is agnostic and defined by the registered IPropertyResolutionEngine classes. 10 | * This acts the similar to Spring's Value annotation 11 | */ 12 | export function Property(prop: keyof propTypes, required = true): PropertyDecorator { 13 | return (target, key): void => { 14 | let original = target[key]; 15 | Reflect.deleteProperty(target, key); 16 | Reflect.defineProperty(target, key, { 17 | configurable: true, 18 | enumerable: true, 19 | get: () => { 20 | const propValue = manager.getProperty(prop); 21 | if (required && propValue === null) { 22 | throw new Error(`Unable to find prop with key "${prop}"`); 23 | } 24 | if (!required && propValue === null && original !== null && original !== undefined) { 25 | return original; 26 | } 27 | return propValue; 28 | }, 29 | set: (newVal) => { 30 | original = newVal; 31 | } 32 | }); 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/model/framework/manager/AviationStackManager.ts: -------------------------------------------------------------------------------- 1 | import { singleton } from "tsyringe"; 2 | 3 | import logger from "../../../utils/LoggerFactory.js"; 4 | import type { AviationStackInfo } from "../../Typeings.js"; 5 | import { Property } from "../decorators/Property.js"; 6 | import { AbstractRequestEngine } from "../engine/impl/AbstractRequestEngine.js"; 7 | 8 | @singleton() 9 | export class AviationStackManager extends AbstractRequestEngine { 10 | @Property("AVIATION_STACK_TOKEN") 11 | private static readonly token: string; 12 | 13 | public constructor() { 14 | super("https://api.aviationstack.com/v1/flights", { 15 | params: { 16 | access_key: AviationStackManager.token 17 | } 18 | }); 19 | } 20 | 21 | public async getFlightInfo(callsign: string): Promise { 22 | try { 23 | const { data } = await this.api.get(null, { 24 | params: { 25 | flight_icao: callsign, 26 | flight_status: "active" 27 | } 28 | }); 29 | if (data.data.length > 0) { 30 | return data.data[0]; 31 | } 32 | return Promise.reject(new Error(`no aircraft available at the moment with call sign ${callsign}`)); 33 | } catch (error) { 34 | logger.error(`[x] ${error}`); 35 | return Promise.reject(new Error(`no aircraft available at the moment with call sign ${callsign}`)); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/model/framework/manager/AeroDataBoxManager.ts: -------------------------------------------------------------------------------- 1 | import { singleton } from "tsyringe"; 2 | 3 | import logger from "../../../utils/LoggerFactory.js"; 4 | import { ObjectUtil } from "../../../utils/Utils.js"; 5 | import type { AeroDataBoxInfo } from "../../Typeings.js"; 6 | import { Property } from "../decorators/Property.js"; 7 | import { AbstractRequestEngine } from "../engine/impl/AbstractRequestEngine.js"; 8 | 9 | @singleton() 10 | export class AeroDataBoxManager extends AbstractRequestEngine { 11 | @Property("AERO_DATA_BOX_TOKEN") 12 | private static readonly token: string; 13 | 14 | public constructor() { 15 | super("https://aerodatabox.p.rapidapi.com/aircrafts/", { 16 | headers: { 17 | "x-rapidapi-key": AeroDataBoxManager.token 18 | } 19 | }); 20 | } 21 | 22 | public async getAircraftInfo(icao24: string): Promise { 23 | if (!ObjectUtil.validString(icao24)) { 24 | return Promise.reject(new Error(`no aircraft available at the moment with icao24 ${icao24}`)); 25 | } 26 | try { 27 | const result = await this.api.get(`/icao24/${icao24}`); 28 | if (result.status !== 200) { 29 | throw new Error(`call to /icao24 failed with ${result.status}`); 30 | } 31 | return result.data; 32 | } catch (error) { 33 | logger.error(`[x] ${error}`); 34 | return Promise.reject(new Error(`no aircraft available at the moment with icao24 ${icao24}`)); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/LoggerFactory.ts: -------------------------------------------------------------------------------- 1 | import type winston from "winston"; 2 | import { createLogger, format, transports } from "winston"; 3 | import type * as Transport from "winston-transport"; 4 | 5 | import { Property } from "../model/framework/decorators/Property.js"; 6 | import type { NODE_ENV } from "../model/Typeings.js"; 7 | 8 | class LoggerFactory { 9 | @Property("NODE_ENV") 10 | private _environment: NODE_ENV; 11 | 12 | private readonly _logger: winston.Logger; 13 | 14 | public constructor() { 15 | const { combine, splat, timestamp, printf } = format; 16 | 17 | const myFormat = printf(({ level: l, message: m, timestamp: t, ...metadata }) => { 18 | let msg = `⚡ ${t} [${l}] : ${m} `; 19 | if (metadata && JSON.stringify(metadata) !== "{}") { 20 | msg += JSON.stringify(metadata); 21 | } 22 | return msg; 23 | }); 24 | 25 | const transportsArray: Transport[] = [ 26 | new transports.Console({ 27 | level: "debug", 28 | format: combine(format.colorize(), splat(), timestamp(), myFormat) 29 | }), 30 | new transports.File({ 31 | level: "info", 32 | format: format.combine(format.timestamp(), format.json()), 33 | filename: `${process.cwd()}/combined.log` 34 | }) 35 | ]; 36 | 37 | this._logger = createLogger({ 38 | level: "debug", 39 | transports: transportsArray 40 | }); 41 | } 42 | 43 | public get logger(): winston.Logger { 44 | return this._logger; 45 | } 46 | } 47 | 48 | const logger = new LoggerFactory().logger; 49 | export default logger; 50 | -------------------------------------------------------------------------------- /src/model/framework/manager/AirportManager.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import type { AutocompleteInteraction } from "discord.js"; 3 | import { singleton } from "tsyringe"; 4 | 5 | import type { AirportFrequencyResponse, IcaoCode } from "../../Typeings.js"; 6 | import { AbstractRequestEngine } from "../engine/impl/AbstractRequestEngine.js"; 7 | import type { ISearchBase } from "../ISearchBase.js"; 8 | import { autoCompleteBaseUrl } from "../ISearchBase.js"; 9 | 10 | @singleton() 11 | export class AirportManager implements ISearchBase { 12 | public async getAirport(icao: string): Promise { 13 | const searchResult = await axios.get(`${autoCompleteBaseUrl}/airport/getAirport`, { 14 | params: { 15 | icao 16 | }, 17 | ...AbstractRequestEngine.baseOptions 18 | }); 19 | return searchResult.data; 20 | } 21 | 22 | public async getAirportFrequencies(icao: string): Promise { 23 | const searchResult = await axios.get(`${autoCompleteBaseUrl}/airport/getFrequencies`, { 24 | params: { 25 | icao 26 | }, 27 | ...AbstractRequestEngine.baseOptions 28 | }); 29 | return searchResult.data; 30 | } 31 | 32 | public async search(interaction: AutocompleteInteraction): Promise { 33 | const query = interaction.options.getFocused(true).value as string; 34 | const searchResult = await axios.get(`${autoCompleteBaseUrl}/airport/search`, { 35 | params: { 36 | query, 37 | maxLength: 97 38 | }, 39 | ...AbstractRequestEngine.baseOptions 40 | }); 41 | return searchResult.data; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/model/framework/engine/impl/AbstractRequestEngine.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosInstance, AxiosRequestConfig, AxiosRequestHeaders } from "axios"; 2 | import axios from "axios"; 3 | import { container } from "tsyringe"; 4 | 5 | import logger from "../../../../utils/LoggerFactory.js"; 6 | import { Mongo } from "../../../db/Mongo.js"; 7 | 8 | export type InterceptorOptions = { 9 | headers?: AxiosRequestHeaders; 10 | params?: Record; 11 | }; 12 | 13 | export abstract class AbstractRequestEngine { 14 | public readonly baseUrl: string; 15 | protected readonly api: AxiosInstance; 16 | private readonly mongo: Mongo; 17 | 18 | protected constructor(baseURL: string, opts?: InterceptorOptions) { 19 | this.api = this.axiosInterceptor( 20 | axios.create({ 21 | ...AbstractRequestEngine.baseOptions, 22 | baseURL, 23 | ...opts 24 | }) 25 | ); 26 | this.baseUrl = baseURL; 27 | this.mongo = container.resolve(Mongo); 28 | } 29 | 30 | public static get baseOptions(): AxiosRequestConfig { 31 | return { 32 | timeout: 10000, 33 | // only treat 5xx as errors 34 | validateStatus: (status): boolean => !(status >= 500 && status < 600) 35 | }; 36 | } 37 | 38 | private axiosInterceptor(axiosInstance: AxiosInstance): AxiosInstance { 39 | axiosInstance.interceptors.request.use(async (request) => { 40 | try { 41 | const hostname = new URL(request.baseURL).hostname; 42 | await this.mongo.increaseAPIUsage(hostname); 43 | } catch (error) { 44 | logger.error(`[*] ${error}`); 45 | } 46 | return request; 47 | }); 48 | return axiosInstance; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/api/service/BotInfoService.ts: -------------------------------------------------------------------------------- 1 | import { ShardingManager } from "discord.js"; 2 | import { singleton } from "tsyringe"; 3 | 4 | import { Mongo } from "../../model/db/Mongo.js"; 5 | import type { BotInfoFromApi } from "../../model/Typeings.js"; 6 | import { ObjectUtil } from "../../utils/Utils.js"; 7 | import { ShardInfoService } from "./ShardInfoService.js"; 8 | 9 | @singleton() 10 | export class BotInfoService { 11 | public constructor(private _shardingManager: ShardingManager, private _mongo: Mongo, private _shardInfoService: ShardInfoService) {} 12 | 13 | public async getBotInfo(top: string): Promise { 14 | const guildShards = await this._shardInfoService.getShardGuilds(); 15 | if (guildShards.length === 0) { 16 | return null; 17 | } 18 | const retObj: BotInfoFromApi = { 19 | numberOfGuilds: guildShards.length 20 | }; 21 | 22 | // non unique members 23 | retObj["totalMembers"] = guildShards.reduce((partialSum, a) => partialSum + a.memberCount, 0); 24 | 25 | const commandCount = await this._mongo.getCommandCounts(); 26 | retObj["totalCommandsUsed"] = commandCount.total; 27 | if (ObjectUtil.validString(top)) { 28 | const parsed = Number.parseInt(top); 29 | if (!Number.isNaN(parsed)) { 30 | if (parsed > guildShards.length) { 31 | throw new Error(`You have asked for the top ${parsed} servers, but your bot is only in ${guildShards.length} servers`); 32 | } 33 | if (parsed < 1) { 34 | throw new Error(`top server query param must be more than 0`); 35 | } 36 | retObj["top"] = this._shardInfoService.getDiscordServerInfo(guildShards, parsed); 37 | } 38 | } 39 | return retObj; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Bot.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | 3 | import { dirname, importx } from "@discordx/importer"; 4 | import { IntentsBitField } from "discord.js"; 5 | import { Client, DIService, tsyringeDependencyRegistryEngine } from "discordx"; 6 | import { container } from "tsyringe"; 7 | 8 | import { Property } from "./model/framework/decorators/Property.js"; 9 | import { AutoCompleteHealthChecker } from "./model/logic/AutoCompleteHealthChecker.js"; 10 | import type { NODE_ENV } from "./model/Typeings.js"; 11 | 12 | // polly-fill for bigint serialisation 13 | (BigInt.prototype as any).toJSON = function (): string { 14 | return this.toString(); 15 | }; 16 | 17 | class Bot { 18 | @Property("DISCORD_TOKEN") 19 | private static readonly token: string; 20 | 21 | @Property("NODE_ENV") 22 | private static readonly environment: NODE_ENV; 23 | 24 | public static async start(): Promise { 25 | DIService.engine = tsyringeDependencyRegistryEngine.setInjector(container); 26 | const clientOps = { 27 | intents: [IntentsBitField.Flags.Guilds, IntentsBitField.Flags.DirectMessages, IntentsBitField.Flags.GuildVoiceStates], 28 | silent: this.environment !== "development" 29 | }; 30 | if (this.environment === "development") { 31 | clientOps["botGuilds"] = [(client: Client): string[] => client.guilds.cache.map((guild) => guild.id)]; 32 | } 33 | const client = new Client(clientOps); 34 | 35 | if (!container.isRegistered(Client)) { 36 | container.registerInstance(Client, client); 37 | } 38 | const healthChecker = container.resolve(AutoCompleteHealthChecker); 39 | await healthChecker.healthCheck(); 40 | await importx(`${dirname(import.meta.url)}/{events,commands}/**/*.{ts,js}`); 41 | await client.login(Bot.token); 42 | } 43 | } 44 | 45 | await Bot.start(); 46 | -------------------------------------------------------------------------------- /src/model/logic/AutoCompleteHealthChecker.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosResponse } from "axios"; 2 | import axios from "axios"; 3 | import { singleton } from "tsyringe"; 4 | 5 | import METHOD_EXECUTOR_TIME_UNIT from "../../enums/METHOD_EXECUTOR_TIME_UNIT.js"; 6 | import logger from "../../utils/LoggerFactory.js"; 7 | import { RunEvery } from "../framework/decorators/RunEvery.js"; 8 | import { AbstractRequestEngine } from "../framework/engine/impl/AbstractRequestEngine.js"; 9 | import { autoCompleteAppUrl } from "../framework/ISearchBase.js"; 10 | 11 | export type StatusCheckResult = { status: string }; 12 | 13 | @singleton() 14 | export class AutoCompleteHealthChecker { 15 | @RunEvery(30, METHOD_EXECUTOR_TIME_UNIT.seconds) 16 | public async healthCheck(): Promise { 17 | let result: AxiosResponse = null; 18 | try { 19 | result = await axios.get(`${autoCompleteAppUrl}/app/health`, { 20 | ...AbstractRequestEngine.baseOptions, 21 | timeout: 1000 22 | }); 23 | } catch (e) { 24 | this.throwError(e); 25 | } 26 | if (!result) { 27 | this.throwError(); 28 | } 29 | if (result && result.status !== 200) { 30 | this.throwError(result); 31 | } 32 | if (result?.data.status !== "ok") { 33 | this.throwError(result); 34 | } 35 | } 36 | 37 | private throwError(result?: AxiosResponse): never { 38 | let errorMessage: string; 39 | if (result) { 40 | errorMessage = `Unable to communicate to data endpoint: ${axios.isAxiosError(result) ? result.code : result.status} - ${axios.isAxiosError(result) ? result.message : result.statusText}`; 41 | } else { 42 | errorMessage = `Unable to communicate to data endpoint`; 43 | } 44 | logger.error(errorMessage); 45 | throw new Error(errorMessage); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/model/framework/manager/NatsManager.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import isBetween from "dayjs/plugin/isBetween.js"; 3 | import { singleton } from "tsyringe"; 4 | 5 | import logger from "../../../utils/LoggerFactory.js"; 6 | import { ObjectUtil } from "../../../utils/Utils.js"; 7 | import type { Nats } from "../../Typeings.js"; 8 | import { AbstractRequestEngine } from "../engine/impl/AbstractRequestEngine.js"; 9 | 10 | @singleton() 11 | export class NatsManager extends AbstractRequestEngine { 12 | static { 13 | dayjs.extend(isBetween); 14 | } 15 | 16 | public constructor() { 17 | super("https://api.flightplandatabase.com/nav/NATS"); 18 | } 19 | 20 | public getAllTracks(): Promise { 21 | return this.getNatsInfo(); 22 | } 23 | 24 | public async getTrackInformation(ident: string): Promise { 25 | const NatsArr = await this.getNatsInfo(ident); 26 | return NatsArr[0]; 27 | } 28 | 29 | private async getNatsInfo(ident?: string): Promise { 30 | const errorMessage = ObjectUtil.validString(ident) ? `no NAT available at the moment with ident ${ident}` : "no NATs available at the moment"; 31 | try { 32 | const result = await this.api.get(null); 33 | if (result.status !== 200) { 34 | throw new Error(`call to /nav/NATS failed with ${result.status}`); 35 | } 36 | const data = result.data; 37 | let filteredNats = data.filter((nat) => dayjs().isBetween(nat.validFrom, nat.validTo, "minute", "[]")); 38 | if (ObjectUtil.validString(ident)) { 39 | filteredNats = filteredNats.filter((nat) => nat.ident === ident); 40 | } 41 | if (filteredNats.length > 0) { 42 | return filteredNats; 43 | } 44 | return Promise.reject(new Error(errorMessage)); 45 | } catch (error) { 46 | logger.error(`[x] ${error}`); 47 | return Promise.reject(new Error(errorMessage)); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "drph4nt0m", 10 | "name": "Rahul Singh", 11 | "avatar_url": "https://avatars0.githubusercontent.com/u/22918499?v=4", 12 | "profile": "http://dr.ph4nt0m.me", 13 | "contributions": [ 14 | "infra", 15 | "code", 16 | "doc" 17 | ] 18 | }, 19 | { 20 | "login": "radiantly", 21 | "name": "Joshua T.", 22 | "avatar_url": "https://avatars2.githubusercontent.com/u/44368997?v=4", 23 | "profile": "https://xkcd.com/1597/", 24 | "contributions": [ 25 | "code" 26 | ] 27 | }, 28 | { 29 | "login": "Fedelaus", 30 | "name": "Nathan Dawson", 31 | "avatar_url": "https://avatars2.githubusercontent.com/u/43784056?v=4", 32 | "profile": "https://github.com/Fedelaus", 33 | "contributions": [ 34 | "code" 35 | ] 36 | }, 37 | { 38 | "login": "ransbachm", 39 | "name": "ransbachm", 40 | "avatar_url": "https://avatars0.githubusercontent.com/u/25692733?v=4", 41 | "profile": "https://github.com/ransbachm", 42 | "contributions": [ 43 | "code" 44 | ] 45 | }, 46 | { 47 | "login": "VictoriqueMoe", 48 | "name": "Victorique", 49 | "avatar_url": "https://avatars.githubusercontent.com/u/27996712?v=4", 50 | "profile": "https://victorique.moe", 51 | "contributions": [ 52 | "infra", 53 | "code", 54 | "maintenance" 55 | ] 56 | }, 57 | { 58 | "login": "abaza738", 59 | "name": "Maher Abaza", 60 | "avatar_url": "https://avatars.githubusercontent.com/u/50132270?v=4", 61 | "profile": "https://github.com/abaza738", 62 | "contributions": [ 63 | "code" 64 | ] 65 | } 66 | ], 67 | "contributorsPerLine": 7, 68 | "projectName": "avbot", 69 | "projectOwner": "drph4nt0m", 70 | "repoType": "github", 71 | "repoHost": "https://github.com", 72 | "skipCi": true 73 | } 74 | -------------------------------------------------------------------------------- /src/model/framework/decorators/RunEvery.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "discordx"; 2 | import { AsyncTask, SimpleIntervalJob, ToadScheduler } from "toad-scheduler"; 3 | import { container } from "tsyringe"; 4 | 5 | import type METHOD_EXECUTOR_TIME_UNIT from "../../../enums/METHOD_EXECUTOR_TIME_UNIT.js"; 6 | import logger from "../../../utils/LoggerFactory.js"; 7 | 8 | export const scheduler = new ToadScheduler(); 9 | 10 | /** 11 | * Run a method on this bean every x as defined by the time unit.
12 | * Note: the class containing this method must be registered with tsyringe for this decorator to work 13 | * @param time 14 | * @param timeUnit 15 | * @param runImmediately 16 | * @constructor 17 | */ 18 | export function RunEvery(time: number, timeUnit: METHOD_EXECUTOR_TIME_UNIT | string, runImmediately = false): (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => void { 19 | const client = container.isRegistered(Client) ? container.resolve(Client) : null; 20 | return function (target: unknown, propertyKey: string, descriptor: PropertyDescriptor): void { 21 | container.afterResolution( 22 | target.constructor as never, 23 | (_t, result) => { 24 | const task = new AsyncTask( 25 | `${target.constructor.name}.${propertyKey}`, 26 | () => { 27 | return descriptor.value.call(result, client); 28 | }, 29 | (err) => { 30 | logger.error(err); 31 | } 32 | ); 33 | const job = new SimpleIntervalJob( 34 | { 35 | runImmediately, 36 | [timeUnit]: time 37 | }, 38 | task 39 | ); 40 | logger.info(`Register method: "${target.constructor.name}.${propertyKey}()" to run every ${time} ${timeUnit}`); 41 | scheduler.addSimpleIntervalJob(job); 42 | }, 43 | { 44 | frequency: "Once" 45 | } 46 | ); 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/guards/PremiumGuild.ts: -------------------------------------------------------------------------------- 1 | import type { CommandInteraction } from "discord.js"; 2 | import { EmbedBuilder } from "discord.js"; 3 | import type { Client, Next } from "discordx"; 4 | import { container } from "tsyringe"; 5 | 6 | import { Mongo } from "../model/db/Mongo.js"; 7 | import { PropertyResolutionManager } from "../model/framework/manager/PropertyResolutionManager.js"; 8 | import logger from "../utils/LoggerFactory.js"; 9 | import { InteractionUtils } from "../utils/Utils.js"; 10 | 11 | const mongo = container.resolve(Mongo); 12 | const propertyResolutionManager = container.resolve(PropertyResolutionManager); 13 | 14 | /** 15 | * Guard that will only pass when the guild is a premium guild 16 | * @param {CommandInteraction} arg 17 | * @param {Client} client 18 | * @param {Next} next 19 | * @constructor 20 | */ 21 | export async function PremiumGuild(arg: CommandInteraction, client: Client, next: Next): Promise { 22 | const guildId = arg.guildId; 23 | const isPremium = await mongo.isPremiumGuild(guildId); 24 | const member = arg.member; 25 | if (!isPremium) { 26 | logger.error(`[${client.shard.ids}] ${guildId} tried using ${arg.commandName} command`); 27 | try { 28 | const inviteUrl = propertyResolutionManager.getProperty("SUPPORT_SERVER_INVITE"); 29 | const premiumEmbed = new EmbedBuilder() 30 | .setTimestamp() 31 | .setColor("#00ff00") 32 | .setDescription( 33 | `${member}, this command is only available for premium servers. If you want to join the premium program, join [AvBot Support Server](${inviteUrl}) and contact the developer.` 34 | ) 35 | .setFooter({ 36 | text: `${client.user.username} • @dr_ph4nt0m#8402` 37 | }); 38 | return InteractionUtils.replyOrFollowUp(arg, { 39 | embeds: [premiumEmbed] 40 | }); 41 | } catch { 42 | // it failed, for some reason, to send the embed, but we still don't want to continue, just let the interaction fail 43 | } 44 | return; 45 | } 46 | return next(); 47 | } 48 | -------------------------------------------------------------------------------- /src/events/GuildCreate.ts: -------------------------------------------------------------------------------- 1 | import type { TextChannel } from "discord.js"; 2 | import { ChannelType, EmbedBuilder, hyperlink, inlineCode } from "discord.js"; 3 | import type { ArgsOf, Client } from "discordx"; 4 | import { Discord, On } from "discordx"; 5 | import { injectable } from "tsyringe"; 6 | 7 | import { Property } from "../model/framework/decorators/Property.js"; 8 | import type { NODE_ENV } from "../model/Typeings.js"; 9 | import logger from "../utils/LoggerFactory.js"; 10 | import { OnReady } from "./OnReady.js"; 11 | 12 | @Discord() 13 | @injectable() 14 | export class GuildCreate { 15 | @Property("NODE_ENV") 16 | private readonly environment: NODE_ENV; 17 | 18 | public constructor(private _onReady: OnReady) {} 19 | 20 | @On({ 21 | event: "guildCreate" 22 | }) 23 | private async botJoins([guild]: ArgsOf<"guildCreate">, client: Client): Promise { 24 | try { 25 | const welcomeEmbed = new EmbedBuilder() 26 | .setTitle(`Hello ${guild.name} and thank you for choosing AvBot`) 27 | .setColor("#1a8fe3") 28 | .setDescription( 29 | `If you need any help regarding AvBot or have any suggestions 30 | join our ${hyperlink("AvBot Support Server", "https://go.av8.dev/support")}. 31 | To get started try ${inlineCode("\\help")}. 32 | ${hyperlink("Support AvBot", "https://go.av8.dev/donate")}` 33 | ) 34 | .setFooter({ 35 | text: `${client.user.username} • @dr_ph4nt0m#8402 • Thank you for showing your support by using AvBot` 36 | }) 37 | .setTimestamp(); 38 | const textChannel = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText).first() as TextChannel; 39 | await textChannel.send({ 40 | embeds: [welcomeEmbed] 41 | }); 42 | } catch (error) { 43 | logger.error(`[${client.shard.ids}] ${error}`); 44 | } 45 | 46 | if (this.environment === "development") { 47 | await this._onReady.initAppCommands(client); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/api/BotServer.ts: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from "@discordx/importer"; 2 | import { Server } from "@overnightjs/core"; 3 | import bodyParser from "body-parser"; 4 | import cors from "cors"; 5 | import type * as http from "http"; 6 | import { container, singleton } from "tsyringe"; 7 | 8 | import { PostConstruct } from "../model/framework/decorators/PostConstruct.js"; 9 | import { Property } from "../model/framework/decorators/Property.js"; 10 | import type { NODE_ENV } from "../model/Typeings.js"; 11 | import logger from "../utils/LoggerFactory.js"; 12 | 13 | @singleton() 14 | export class BotServer extends Server { 15 | @Property("API_SERVER_PORT") 16 | private readonly port: number; 17 | 18 | @Property("NODE_ENV") 19 | private readonly env: NODE_ENV; 20 | 21 | private readonly classesToLoad = `${dirname(import.meta.url)}/controllers/**/*.{ts,js}`; 22 | 23 | public constructor() { 24 | super(); 25 | this.app.use( 26 | cors({ 27 | origin: this.env === "development" ? "*" : /\.av8\.dev$/ 28 | }) 29 | ); 30 | this.app.use(bodyParser.json()); 31 | this.app.use(bodyParser.urlencoded({ extended: true })); 32 | } 33 | 34 | private _server: http.Server = null; 35 | 36 | public get server(): http.Server { 37 | return this._server; 38 | } 39 | 40 | public start(port: number): http.Server { 41 | return this.app.listen(port, () => { 42 | logger.info(`Server listening on port: ${port}`); 43 | }); 44 | } 45 | 46 | @PostConstruct 47 | private async init(): Promise { 48 | if (this._server) { 49 | return; 50 | } 51 | const files = resolve(this.classesToLoad); 52 | const pArr = files.map((file) => import(file)); 53 | const modules = await Promise.all(pArr); 54 | for (const module of modules) { 55 | const moduleKey = Object.keys(module)[0]; 56 | const clazz = module[moduleKey]; 57 | if (container.isRegistered(clazz)) { 58 | const instance = container.resolve(clazz); 59 | super.addControllers(instance); 60 | logger.info(`load ${moduleKey}`); 61 | } 62 | } 63 | this._server = this.start(this.port); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/model/framework/manager/AbstractCallSignInformationManager.ts: -------------------------------------------------------------------------------- 1 | import type { AutocompleteInteraction } from "discord.js"; 2 | 3 | import logger from "../../../utils/LoggerFactory.js"; 4 | import type { IvaoAtc, IvaoInfo, IvaoPilot, PosconAtc, PosconFlight, PosconInfo, VatsimAtc, VatsimInfo, VatsimPilot } from "../../Typeings.js"; 5 | import { AbstractRequestEngine } from "../engine/impl/AbstractRequestEngine.js"; 6 | import type { ISearchBase, SearchBase } from "../ISearchBase.js"; 7 | 8 | type SearchType = SearchBase & { type: "pilot" | "atc" }; 9 | type Merged = VatsimInfo | IvaoInfo | PosconInfo; 10 | 11 | export abstract class AbstractCallSignInformationManager extends AbstractRequestEngine implements ISearchBase { 12 | public async getInfo(): Promise { 13 | const result = await this.api.get(`/`); 14 | if (result.status !== 200) { 15 | logger.error(`[x] ${result.data.message}`); 16 | throw new Error(result.data.message); 17 | } 18 | return result.data; 19 | } 20 | 21 | public async getClientInfo(callSign: string, type: "pilot" | "atc"): Promise { 22 | try { 23 | const result = await this.api.get("/getClientInfo", { 24 | params: { 25 | type, 26 | callSign 27 | } 28 | }); 29 | if (result.status !== 200) { 30 | throw new Error(`call to /getClientInfo failed with ${result.status}`); 31 | } 32 | return result.data; 33 | } catch (error) { 34 | logger.error(`[x] ${error}`); 35 | return Promise.reject(new Error(error.response ? error.response.data.message : `no client available at the moment with call sign ${callSign}`)); 36 | } 37 | } 38 | 39 | public async search(interaction: AutocompleteInteraction): Promise { 40 | const selectedType: string = interaction.options.getString("type") ?? "pilot"; 41 | const query = interaction.options.getFocused(true).value as string; 42 | const result = await this.api.get("/search", { 43 | params: { 44 | type: selectedType, 45 | query 46 | } 47 | }); 48 | if (result.status !== 200) { 49 | return []; 50 | } 51 | return result.data; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | 118 | **/tmp/* 119 | .openode 120 | .env.* 121 | !.env.example 122 | /build 123 | **build/** 124 | **.idea/** 125 | .idea/ 126 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to AvBot 2 | 3 | First of all, _thank you for considering contributing_. It helps a **lot** and helps remove some of the burden from our developer team. 4 | 5 | This file will assist you with everything you need to know about [**_setting up a AvBot development/testing environment_**](#setting-up-a-developmenttesting-environment), [**_writing code that will be approved_**](#writing-code-that-will-be-approved), and [**_submitting your code_**](#submitting-your-code). 6 | 7 | ## Setting up a development/testing environment 8 | 9 | In order to work on AvBot, you need a working development environment. 10 | 11 | ### Step 1: Prerequisites 12 | 13 | AvBot has some prerequisites that need to be met in order to test your new features. 14 | 15 | #### Node.js 16 | 17 | For AvBot to function, you need to install the [Node.js JavaScript Runtime](https://nodejs.org/). 18 | 19 | Install version `16.x.x.`. 20 | 21 | #### NPM 22 | 23 | NPM comes built in with [Node.js](#nodejs), so there isn't a need to worry about it. 24 | 25 | ### Step 2: Installing dependencies 26 | 27 | Now, run the command `npm install` in your terminal. This will install all of our dependencies. 28 | 29 | ## Writing code that will be approved 30 | 31 | When you write your code, it should be fit enough to be merged. 32 | 33 | Your code should be formatted with Prettier after you write it, if you don't then a bot will detect your pull request and do that for you. 34 | 35 | Code should be reasonable, and we think you are able to understand what that means. 36 | 37 | ## Submitting your code 38 | 39 | ### WARNING 40 | 41 | Ensure you are on your _fork_ before continuing. Create one on GitHub. 42 | 43 | ### Step 1: Commiting 44 | 45 | Run the following command: 46 | 47 | ```bash 48 | git commit -m "Describe your changes here. Try to use less than 50 characters." 49 | 50 | # Or, for an extended description: 51 | 52 | git commit -m "Describe your changes here. Try to use less than 50 characters. 53 | 54 | Put your extended description here." 55 | ``` 56 | 57 | ### Step 2: Pushing 58 | 59 | Push to your fork with this command: 60 | 61 | ```bash 62 | git push 63 | ``` 64 | 65 | #### If you get a "cannot find upstream branch" error, run 66 | 67 | ```bash 68 | git push --set-upstream origin master 69 | ``` 70 | 71 | ### Step 3: Creating a Pull Request 72 | 73 | On GitHub, select `Pull Requests` and open a new one. 74 | 75 | #### Step 3.1: Fill out the template 76 | 77 | Fill out our Pull Request template. 78 | 79 | Put useful information about the changes there. 80 | 81 | ### Step 4: All done 82 | 83 | Your changes will be reviewed soon. Keep an eye on your pull request! 84 | -------------------------------------------------------------------------------- /src/model/framework/manager/Av8Manager.ts: -------------------------------------------------------------------------------- 1 | import dayjs, { Dayjs } from "dayjs"; 2 | import utc from "dayjs/plugin/utc.js"; 3 | import { singleton } from "tsyringe"; 4 | 5 | import logger from "../../../utils/LoggerFactory.js"; 6 | import { ObjectUtil } from "../../../utils/Utils.js"; 7 | import type { Notam } from "../../Typeings.js"; 8 | import { NotamType } from "../../Typeings.js"; 9 | import { Property } from "../decorators/Property.js"; 10 | import { AbstractRequestEngine } from "../engine/impl/AbstractRequestEngine.js"; 11 | 12 | @singleton() 13 | export class Av8Manager extends AbstractRequestEngine { 14 | static { 15 | dayjs.extend(utc); 16 | } 17 | 18 | @Property("AV8_TOKEN") 19 | private static readonly av8Token: string; 20 | 21 | public constructor() { 22 | super("https://api.av8.dev/api/", { 23 | headers: { 24 | Authorization: Av8Manager.av8Token 25 | } 26 | }); 27 | } 28 | 29 | public async getNotams(icao: string, upcoming = false): Promise { 30 | try { 31 | const response = await this.api.get<{ icao: string; notams: Notam[] }>(`/notams/${icao}`); 32 | 33 | if (response.status !== 200) { 34 | return Promise.reject(new Error(`no notams available at the moment for ${icao}`)); 35 | } 36 | 37 | const validNotams = response.data.notams 38 | .filter((notam) => notam.validity.isValid === true) 39 | .map((notam) => ({ 40 | ...notam, 41 | type: notam.validity.phrase.startsWith("Will be active in") ? NotamType.UPCOMING : NotamType.ACTIVE, 42 | from: notam.from !== "PERMANENT" ? dayjs(notam.from) : notam.from, 43 | to: notam.to !== "PERMANENT" ? dayjs(notam.to) : notam.to 44 | })); 45 | 46 | const notams = validNotams.filter((notam) => notam.type === NotamType.ACTIVE).sort((a, b) => (b.from as Dayjs).diff(a.from as Dayjs)); 47 | 48 | if (upcoming) { 49 | notams.push(...validNotams.filter((notam) => notam.type === NotamType.UPCOMING).sort((a, b) => (a.from as Dayjs).diff(b.from as Dayjs))); 50 | } 51 | 52 | if (ObjectUtil.isValidArray(notams) === false) { 53 | return Promise.reject(new Error(`no notams available at the moment for ${icao}`)); 54 | } 55 | 56 | return notams; 57 | } catch (error) { 58 | logger.error(`[x] ${error}`); 59 | return Promise.reject(new Error(error.response ? error.response.data.message : `no notams available at the moment for ${icao}`)); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | 3 | import { Colors, Shard, ShardingManager, WebhookClient } from "discord.js"; 4 | import dotenv from "dotenv"; 5 | import { container } from "tsyringe"; 6 | 7 | import { BotServer } from "./api/BotServer.js"; 8 | import { Property } from "./model/framework/decorators/Property.js"; 9 | import logger from "./utils/LoggerFactory.js"; 10 | import { InteractionUtils, ObjectUtil } from "./utils/Utils.js"; 11 | 12 | export class Main { 13 | @Property("DISCORD_TOKEN") 14 | private static readonly token: string; 15 | 16 | @Property("RESTART_NOTIFICATION_WEBHOOK", false) 17 | private static readonly restartNotificationWebhook: string; 18 | 19 | private static readonly shardUptimeMap: Map = new Map(); 20 | 21 | public static async start(): Promise { 22 | dotenv.config(); 23 | const manager = new ShardingManager("./build/Bot.js", { 24 | token: Main.token 25 | }); 26 | 27 | manager.on("shardCreate", (shard) => { 28 | logger.info(`Launched shard ${shard.id}`); 29 | Main.addShardListeners([shard]); 30 | }); 31 | 32 | await manager.spawn({ 33 | amount: "auto", 34 | delay: 1000, 35 | timeout: -1 36 | }); 37 | setTimeout(async () => { 38 | container.registerInstance(ShardingManager, manager); 39 | container.resolve(BotServer); 40 | if (ObjectUtil.validString(this.restartNotificationWebhook)) { 41 | const webhookClient = new WebhookClient({ url: this.restartNotificationWebhook }); 42 | await InteractionUtils.sendWebhookMessage( 43 | { 44 | title: `AvBot restarted!`, 45 | color: Colors.Blue, 46 | footer: { text: `Shards: ${manager.shards.size}` } 47 | }, 48 | webhookClient 49 | ); 50 | } 51 | }, 10000); 52 | } 53 | 54 | /** 55 | * get shard uptime in milliseconds 56 | * @returns {number} 57 | * @param shard 58 | */ 59 | public static getShardUptime(shard: Shard): number { 60 | if (!Main.shardUptimeMap.has(shard)) { 61 | return -1; 62 | } 63 | const startTime = Main.shardUptimeMap.get(shard); 64 | return Math.round(Date.now() - startTime); 65 | } 66 | 67 | private static addShardListeners(shards: Shard[]): void { 68 | for (const shard of shards) { 69 | shard.on("spawn", () => { 70 | Main.shardUptimeMap.set(shard, Date.now()); 71 | }); 72 | shard.on("death", () => { 73 | Main.shardUptimeMap.delete(shard); 74 | }); 75 | } 76 | } 77 | } 78 | 79 | await Main.start(); 80 | -------------------------------------------------------------------------------- /src/model/framework/manager/OpenSkyManager.ts: -------------------------------------------------------------------------------- 1 | import { singleton } from "tsyringe"; 2 | 3 | import logger from "../../../utils/LoggerFactory.js"; 4 | import type { FlightInfo } from "../../Typeings.js"; 5 | import { AbstractRequestEngine } from "../engine/impl/AbstractRequestEngine.js"; 6 | 7 | @singleton() 8 | export class OpenSkyManager extends AbstractRequestEngine { 9 | public constructor() { 10 | super("https://opensky-network.org/api/states/all"); 11 | } 12 | 13 | public async getFlightInfo(callsign: string): Promise { 14 | try { 15 | const result = await this.api.get(null); 16 | if (result.status !== 200) { 17 | throw new Error(`call to /api/states/all failed with ${result.status}`); 18 | } 19 | const data = result.data; 20 | const allAircraft = [].concat(data.states); 21 | for (const aircraft of allAircraft) { 22 | if (aircraft[1] && aircraft[1].trim() === callsign) { 23 | return { 24 | icao24: aircraft[0] ? aircraft[0].trim() : "Unknown", 25 | callsign: aircraft[1] ? aircraft[1].trim() : "Unknown", 26 | origin_country: aircraft[2] ? aircraft[2].trim() : "Unknown", 27 | time_position: aircraft[3] ? aircraft[3] : "Unknown", 28 | last_contact: aircraft[4] ? aircraft[4] : "Unknown", 29 | longitude: aircraft[5] ? aircraft[5] : "Unknown", 30 | latitude: aircraft[6] ? aircraft[6] : "Unknown", 31 | baro_altitude: aircraft[7] ? `${(aircraft[7] * 3.28084).toFixed(0)} ft` : "On Ground", 32 | on_ground: aircraft[8] ? aircraft[8] : "Unknown", 33 | velocity: aircraft[9] ? `${(aircraft[9] * 1.943844).toFixed(0)} knots` : "Parked", 34 | true_track: aircraft[10] ? `${aircraft[10].toFixed(0)}°` : "Unknown", 35 | vertical_rate: aircraft[11] !== null ? `${(aircraft[11] * 196.8504).toFixed(0)} fpm` : "On Ground", 36 | sensors: aircraft[12] ? aircraft[12] : "Unknown", 37 | geo_altitude: aircraft[13] ? `${(aircraft[13] * 3.28084).toFixed(0)} ft` : "On Ground", 38 | squawk: aircraft[14] ? aircraft[14].trim() : "Unknown", 39 | spi: aircraft[15], 40 | position_source: aircraft[16] 41 | }; 42 | } 43 | } 44 | return Promise.reject(new Error(`no aircraft available at the moment with call sign ${callsign}`)); 45 | } catch (error) { 46 | logger.error(`[x] ${error}`); 47 | return Promise.reject(new Error(`no aircraft available at the moment with call sign ${callsign}`)); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "simple-import-sort", "import"], 5 | "extends": ["plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended", "prettier"], 6 | "rules": { 7 | // simple-import-sort 8 | "simple-import-sort/imports": "error", 9 | "simple-import-sort/exports": "error", 10 | "import/first": "error", 11 | "import/newline-after-import": "error", 12 | "import/no-duplicates": "error", 13 | // common 14 | "no-return-await": "error", 15 | "no-unreachable-loop": "error", 16 | "no-promise-executor-return": "error", 17 | "no-unsafe-optional-chaining": "error", 18 | "no-useless-backreference": "error", 19 | "require-atomic-updates": "error", 20 | "require-await": "error", 21 | "no-await-in-loop": "off", 22 | "spaced-comment": "error", 23 | "sort-imports": "off", 24 | "no-unused-vars": "off", 25 | "curly": "error", 26 | "camelcase": "off", 27 | // TypeScript 28 | "@typescript-eslint/explicit-function-return-type": "off", 29 | "@typescript-eslint/explicit-member-accessibility": "off", 30 | "@typescript-eslint/consistent-type-imports": "off", 31 | "@typescript-eslint/no-use-before-define": "error", 32 | "@typescript-eslint/no-shadow": "error", 33 | "@typescript-eslint/prefer-optional-chain": "error", 34 | "@typescript-eslint/no-unused-vars": "error", 35 | "@typescript-eslint/no-loss-of-precision": "error", 36 | "@typescript-eslint/no-empty-function": "error", 37 | "@typescript-eslint/no-empty-interface": "error", 38 | "@typescript-eslint/no-inferrable-types": "error", 39 | "@typescript-eslint/no-non-null-asserted-optional-chain": "error", 40 | "@typescript-eslint/no-non-null-assertion": "error", 41 | "@typescript-eslint/explicit-module-boundary-types": "off", 42 | "@typescript-eslint/no-var-requires": "error", 43 | "@typescript-eslint/no-explicit-any": "off", 44 | "@typescript-eslint/ban-types": "error" 45 | }, 46 | "overrides": [ 47 | { 48 | "files": ["*.ts", "*.tsx"], 49 | "rules": { 50 | "@typescript-eslint/explicit-function-return-type": [ 51 | "error", 52 | { 53 | "allowConciseArrowFunctionExpressionsStartingWithVoid": true, 54 | "allowDirectConstAssertionInArrowFunctions": true 55 | } 56 | ], 57 | "@typescript-eslint/explicit-member-accessibility": [ 58 | "error", 59 | { 60 | "accessibility": "explicit" 61 | } 62 | ] 63 | } 64 | } 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /src/events/OnShard.ts: -------------------------------------------------------------------------------- 1 | import { Colors, WebhookClient } from "discord.js"; 2 | import { ArgsOf, Discord, On } from "discordx"; 3 | 4 | import { Property } from "../model/framework/decorators/Property.js"; 5 | import type { NODE_ENV } from "../model/Typeings.js"; 6 | import logger from "../utils/LoggerFactory.js"; 7 | import { InteractionUtils, ObjectUtil } from "../utils/Utils.js"; 8 | 9 | @Discord() 10 | export class OnShard { 11 | @Property("SHARD_NOTIFICATION_WEBHOOK", false) 12 | private readonly shardNotificationWebhook: string; 13 | 14 | @Property("NODE_ENV") 15 | private readonly environment: NODE_ENV; 16 | 17 | private readonly webhookClient: WebhookClient; 18 | 19 | public constructor() { 20 | if (ObjectUtil.validString(this.shardNotificationWebhook)) { 21 | this.webhookClient = new WebhookClient({ url: this.shardNotificationWebhook }); 22 | } 23 | } 24 | 25 | @On() 26 | private async shardReady([shardId]: ArgsOf<"shardReady">): Promise { 27 | logger.info(`Shard ${shardId} ready!`); 28 | await InteractionUtils.sendWebhookMessage( 29 | { 30 | title: `Shard ${shardId} ready!`, 31 | color: Colors.Blue 32 | }, 33 | this.webhookClient 34 | ); 35 | } 36 | 37 | @On() 38 | private async shardResume([shardId]: ArgsOf<"shardResume">): Promise { 39 | logger.info(`Shard ${shardId} resumed!`); 40 | await InteractionUtils.sendWebhookMessage( 41 | { 42 | title: `Shard ${shardId} resumed!`, 43 | color: Colors.Blue 44 | }, 45 | this.webhookClient 46 | ); 47 | } 48 | 49 | @On() 50 | private async shardDisconnect([, shardId]: ArgsOf<"shardDisconnect">): Promise { 51 | logger.info(`Shard ${shardId} disconnected!`); 52 | await InteractionUtils.sendWebhookMessage( 53 | { 54 | title: `Shard ${shardId} disconnected!`, 55 | color: Colors.Red 56 | }, 57 | this.webhookClient 58 | ); 59 | } 60 | 61 | @On() 62 | private async shardReconnecting([shardId]: ArgsOf<"shardReconnecting">): Promise { 63 | logger.info(`Shard ${shardId} reconnecting...`); 64 | await InteractionUtils.sendWebhookMessage( 65 | { 66 | title: `Shard ${shardId} reconnecting...`, 67 | color: Colors.Yellow 68 | }, 69 | this.webhookClient 70 | ); 71 | } 72 | 73 | @On() 74 | private async shardError([, shardId]: ArgsOf<"shardError">): Promise { 75 | logger.info(`Shard ${shardId} encountered a connection error!`); 76 | await InteractionUtils.sendWebhookMessage( 77 | { 78 | title: `Shard ${shardId} encountered a connection error!`, 79 | color: Colors.Red 80 | }, 81 | this.webhookClient 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/commands/Info.ts: -------------------------------------------------------------------------------- 1 | import { Category, NotBot } from "@discordx/utilities"; 2 | import { ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction, EmbedBuilder } from "discord.js"; 3 | import { Client, Discord, Guard, Slash } from "discordx"; 4 | import process from "process"; 5 | 6 | import TIME_UNIT from "../enums/TIME_UNIT.js"; 7 | import { RequiredBotPerms } from "../guards/RequiredBotPerms.js"; 8 | import { InteractionUtils, ObjectUtil } from "../utils/Utils.js"; 9 | 10 | @Discord() 11 | @Category("Utility") 12 | export class Info { 13 | @Slash({ 14 | description: "Provides information about AvBot, and links for adding the bot and joining the support server" 15 | }) 16 | @Guard( 17 | NotBot, 18 | RequiredBotPerms({ 19 | textChannel: ["EmbedLinks"] 20 | }) 21 | ) 22 | public info(interaction: CommandInteraction, client: Client): Promise { 23 | const infoEmbed = new EmbedBuilder() 24 | .setTitle(client.user.username) 25 | .setURL("https://bot.av8.dev") 26 | .setColor("#0099ff") 27 | .setThumbnail("https://bot.av8.dev/img/logo.png") 28 | .setFooter({ 29 | text: `${client.user.username} • @dr_ph4nt0m#8402` 30 | }) 31 | .setTimestamp(); 32 | 33 | const inviteButton = new ButtonBuilder() 34 | .setLabel("Add to Discord") 35 | .setStyle(ButtonStyle.Link) 36 | .setURL("https://discord.com/oauth2/authorize?client_id=494888240617095168&permissions=274885302528&scope=bot%20applications.commands"); 37 | const supportServerInvite = new ButtonBuilder().setLabel("Join our support server").setStyle(ButtonStyle.Link).setURL("https://discord.gg/fjNqtz6"); 38 | const donateButton = new ButtonBuilder().setLabel("Support Avbot").setStyle(ButtonStyle.Link).setURL("https://go.av8.dev/donate"); 39 | const buttonsRow = new ActionRowBuilder().addComponents(inviteButton, supportServerInvite, donateButton); 40 | 41 | const shardUptime = process.uptime(); 42 | const humanReadableUptime = ObjectUtil.timeToHuman(shardUptime, TIME_UNIT.seconds); 43 | infoEmbed.addFields(ObjectUtil.singleFieldBuilder("Uptime", humanReadableUptime)); 44 | 45 | let foundInGuild = false; 46 | if (interaction.inGuild()) { 47 | const guild = interaction.guild; 48 | const botOwnerId = "442534266849460224"; 49 | const botOwnerInGuild = guild.members.cache.has(botOwnerId); 50 | if (botOwnerInGuild) { 51 | infoEmbed.addFields(ObjectUtil.singleFieldBuilder("Owner", `<@${botOwnerId}>`)); 52 | foundInGuild = true; 53 | } 54 | } 55 | if (!foundInGuild) { 56 | infoEmbed.addFields(ObjectUtil.singleFieldBuilder("Owner", "dr_ph4nt0m#8402")); 57 | } 58 | return InteractionUtils.replyOrFollowUp(interaction, { 59 | embeds: [infoEmbed], 60 | components: [buttonsRow] 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "avbot", 3 | "version": "4.0.0", 4 | "description": "AvBot", 5 | "author": "Rahul Singh ", 6 | "private": true, 7 | "type": "module", 8 | "main": "build/main.js", 9 | "scripts": { 10 | "build": "tsc", 11 | "dev": "node --loader ts-node/esm/transpile-only src/main.ts", 12 | "serve": "node build/main.js", 13 | "lint": "eslint . --ext .ts --max-warnings 0", 14 | "lint-fix": "eslint . --ext .ts --fix", 15 | "prettier": "prettier . -w", 16 | "start_js": "npm run build & node build/main.js", 17 | "restart_p2": "pm2 restart ../ecosystem/ecosystem.config.js --only avbot", 18 | "restart_p2_debug": "pm2 restart ../ecosystem/ecosystem.config.js --only avbot -- -debug", 19 | "start_p2": "pm2 start ../ecosystem/ecosystem.config.js --only avbot", 20 | "start_p2_debug": "pm2 start ../ecosystem/ecosystem.config.js --only avbot -- -debug", 21 | "build_and_start": "npm run build & npm run restart_p2", 22 | "build_and_start_debug": "npm run build & npm run restart_p2_debug" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/drph4nt0m/avbot.git" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/drph4nt0m/avbot/issues" 30 | }, 31 | "homepage": "https://github.com/drph4nt0m/avbot#readme", 32 | "dependencies": { 33 | "@discordjs/opus": "^0.8.0", 34 | "@discordjs/voice": "^0.11.0", 35 | "@discordx/importer": "^1.1.10", 36 | "@discordx/pagination": "^3.0.0", 37 | "@discordx/utilities": "^5.0.0", 38 | "@overnightjs/core": "^1.7.6", 39 | "@types/common-tags": "^1.8.1", 40 | "axios": "^0.27.2", 41 | "common-tags": "^1.8.2", 42 | "cors": "^2.8.5", 43 | "csvtojson": "^2.0.10", 44 | "dayjs": "^1.11.5", 45 | "discord-api-types": "^0.37.9", 46 | "discord.js": "^14.3.0", 47 | "discordx": "^11.1.12", 48 | "dotenv": "^16.0.2", 49 | "express": "^4.18.1", 50 | "ffmpeg-static": "^5.1.0", 51 | "http-status-codes": "^2.2.0", 52 | "immutable": "^4.1.0", 53 | "libsodium-wrappers": "^0.7.10", 54 | "mongodb": "^4.9.1", 55 | "node-gtts": "^2.0.2", 56 | "opusscript": "0.0.8", 57 | "reflect-metadata": "^0.1.13", 58 | "remove-accents": "^0.4.2", 59 | "tmp": "^0.2.1", 60 | "toad-scheduler": "^1.6.1", 61 | "tsyringe": "^4.7.0", 62 | "winston": "^3.8.2" 63 | }, 64 | "devDependencies": { 65 | "@types/express": "^4.17.13", 66 | "@types/node": "^18.7.16", 67 | "@typescript-eslint/eslint-plugin": "^5.36.2", 68 | "@typescript-eslint/parser": "^5.36.2", 69 | "eslint": "^8.23.1", 70 | "eslint-config-prettier": "^8.5.0", 71 | "eslint-plugin-import": "^2.26.0", 72 | "eslint-plugin-simple-import-sort": "^8.0.0", 73 | "prettier": "2.7.1", 74 | "ts-node": "^10.9.1", 75 | "typescript": "^4.8.3" 76 | }, 77 | "engines": { 78 | "node": ">=16.17.0", 79 | "npm": ">=8.11.0" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/commands/Metar.ts: -------------------------------------------------------------------------------- 1 | import { Category, NotBot } from "@discordx/utilities"; 2 | import { ApplicationCommandOptionType, AutocompleteInteraction, codeBlock, CommandInteraction, EmbedBuilder, inlineCode } from "discord.js"; 3 | import { Client, Discord, Guard, Slash, SlashOption } from "discordx"; 4 | import { injectable } from "tsyringe"; 5 | 6 | import { RequiredBotPerms } from "../guards/RequiredBotPerms.js"; 7 | import { AirportManager } from "../model/framework/manager/AirportManager.js"; 8 | import { AvwxManager } from "../model/framework/manager/AvwxManager.js"; 9 | import logger from "../utils/LoggerFactory.js"; 10 | import { InteractionUtils } from "../utils/Utils.js"; 11 | 12 | @Discord() 13 | @Category("Advisory") 14 | @injectable() 15 | export class Metar { 16 | public constructor(private _avwxManager: AvwxManager) {} 17 | 18 | @Slash({ 19 | description: "Gives you the latest METAR for the chosen airport" 20 | }) 21 | @Guard( 22 | NotBot, 23 | RequiredBotPerms({ 24 | textChannel: ["EmbedLinks"] 25 | }) 26 | ) 27 | public async metar( 28 | @SlashOption({ 29 | name: "icao", 30 | autocomplete: (interaction: AutocompleteInteraction) => InteractionUtils.search(interaction, AirportManager), 31 | description: "What ICAO would you like the bot to give METAR for?", 32 | type: ApplicationCommandOptionType.String, 33 | required: true 34 | }) 35 | icao: string, 36 | @SlashOption({ 37 | name: "raw-only", 38 | description: "Gives you only the raw METAR for the chosen airport", 39 | required: false 40 | }) 41 | rawOnlyData: boolean, 42 | interaction: CommandInteraction, 43 | client: Client 44 | ): Promise { 45 | await interaction.deferReply(); 46 | icao = icao.toUpperCase(); 47 | const metarEmbed = new EmbedBuilder() 48 | .setTitle(`METAR: ${inlineCode(icao)}`) 49 | .setColor("#0099ff") 50 | .setTimestamp(); 51 | try { 52 | const { raw, readable } = await this._avwxManager.getMetar(icao); 53 | if (rawOnlyData) { 54 | metarEmbed.setDescription(codeBlock(raw)); 55 | } else { 56 | metarEmbed.addFields( 57 | { 58 | name: "Raw Report", 59 | value: codeBlock(raw) 60 | }, 61 | { 62 | name: "Readable Report", 63 | value: readable 64 | } 65 | ); 66 | } 67 | } catch (err) { 68 | logger.error(`[${client.shard.ids}] ${err}`); 69 | metarEmbed.setColor("#ff0000").setDescription(`${interaction.member}, ${err.message}`); 70 | } 71 | metarEmbed.setFooter({ 72 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums • Source: AVWX` 73 | }); 74 | return InteractionUtils.replyOrFollowUp(interaction, { 75 | embeds: [metarEmbed] 76 | }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/model/db/Mongo.ts: -------------------------------------------------------------------------------- 1 | import type { Document } from "bson"; 2 | import type { Collection, Db } from "mongodb"; 3 | import { MongoClient } from "mongodb"; 4 | import { singleton } from "tsyringe"; 5 | 6 | import logger from "../../utils/LoggerFactory.js"; 7 | import { Utils } from "../../utils/Utils.js"; 8 | import { PostConstruct } from "../framework/decorators/PostConstruct.js"; 9 | import { Property } from "../framework/decorators/Property.js"; 10 | import type { CommandCount, Settings, Stats } from "../Typeings.js"; 11 | 12 | @singleton() 13 | export class Mongo { 14 | @Property("MONGODB_URI") 15 | private uri: string; 16 | 17 | private _db: Db; 18 | 19 | public get db(): Db { 20 | return this._db; 21 | } 22 | 23 | public async isPremiumGuild(guildId: string): Promise { 24 | try { 25 | const settings = await this.getCollection("settings"); 26 | const guildSettings = await settings.findOne({ guild: guildId }); 27 | return Boolean(guildSettings.isPremium); 28 | } catch (error) { 29 | return false; 30 | } 31 | } 32 | 33 | public increaseCommandCount(command: string): Promise { 34 | return this.update("stats", command); 35 | } 36 | 37 | public async getCommandCounts(): Promise { 38 | try { 39 | const stats = await this.getCollection("stats"); 40 | const counts = await stats.find().toArray(); 41 | let total = 0; 42 | for (const c of counts) { 43 | total += c.count; 44 | } 45 | return { counts, total }; 46 | } catch (error) { 47 | return null; 48 | } 49 | } 50 | 51 | public increaseAPIUsage(hostname: string): Promise { 52 | return this.update("api-usage", hostname); 53 | } 54 | 55 | public async getAPIUsage(): Promise { 56 | const apiUsage = await this.getCollection("api-usage"); 57 | return apiUsage.find().toArray(); 58 | } 59 | 60 | @PostConstruct 61 | private async init(): Promise { 62 | // useNewUrlParser and useUnifiedTopology are no longer supported: https://mongoosejs.com/docs/migrating_to_6.html#no-more-deprecation-warning-options and https://stackoverflow.com/questions/56306484/type-error-using-usenewurlparser-with-mongoose-in-typescript 63 | let mongoClient: MongoClient = null; 64 | try { 65 | mongoClient = await MongoClient.connect(this.uri); 66 | logger.info("MongoDB Connected"); 67 | this._db = mongoClient.db("avbot"); 68 | } catch (error) { 69 | logger.error(error); 70 | } 71 | } 72 | 73 | private async update(collectionName: string, value: string): Promise { 74 | const collection = await this.getCollection(collectionName); 75 | try { 76 | const result = await collection.updateOne({ value }, { $inc: { count: 1 } }, { upsert: true }); 77 | return result.acknowledged; 78 | } catch (error) { 79 | logger.error(`[x] ${error}`); 80 | return false; 81 | } 82 | } 83 | 84 | private async getCollection(collectionToUse: string): Promise> { 85 | try { 86 | while (!this._db) { 87 | // eslint-disable-next-line no-await-in-loop 88 | await Utils.sleep(10000); 89 | } 90 | return this._db.collection(collectionToUse); 91 | } catch (error) { 92 | logger.error(`[x] ${error}`); 93 | throw error; 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/events/OnReady.ts: -------------------------------------------------------------------------------- 1 | import { ActivityType, ChannelType, hideLinkEmbed, InteractionType } from "discord.js"; 2 | import type { ArgsOf, Client } from "discordx"; 3 | import { Discord, DIService, On } from "discordx"; 4 | import { injectable } from "tsyringe"; 5 | 6 | import METHOD_EXECUTOR_TIME_UNIT from "../enums/METHOD_EXECUTOR_TIME_UNIT.js"; 7 | import { Mongo } from "../model/db/Mongo.js"; 8 | import { Property } from "../model/framework/decorators/Property.js"; 9 | import { RunEvery } from "../model/framework/decorators/RunEvery.js"; 10 | import type { NODE_ENV } from "../model/Typeings.js"; 11 | import logger from "../utils/LoggerFactory.js"; 12 | import { InteractionUtils } from "../utils/Utils.js"; 13 | 14 | const { minutes } = METHOD_EXECUTOR_TIME_UNIT; 15 | 16 | @Discord() 17 | @injectable() 18 | export class OnReady { 19 | @Property("NODE_ENV") 20 | private readonly environment: NODE_ENV; 21 | 22 | public constructor(private _mongo: Mongo) {} 23 | 24 | public initAppCommands(client: Client): Promise { 25 | if (this.environment === "production") { 26 | return client.initGlobalApplicationCommands({ 27 | log: true 28 | }); 29 | } 30 | return client.initApplicationCommands(); 31 | } 32 | 33 | @On({ 34 | event: "ready" 35 | }) 36 | private async initialise([client]: [Client]): Promise { 37 | this.initDi(); 38 | await this.initAppCommands(client); 39 | await this.setStatus(client); 40 | logger.info(`[${client.shard.ids}] Logged in as ${client.user.tag}! (${client.user.id})`); 41 | } 42 | 43 | @On({ 44 | event: "interactionCreate" 45 | }) 46 | private async intersectionInit([interaction]: ArgsOf<"interactionCreate">, client: Client): Promise { 47 | try { 48 | await client.executeInteraction(interaction); 49 | if (interaction.type === InteractionType.ApplicationCommand) { 50 | try { 51 | await this._mongo.increaseCommandCount(interaction.commandName); 52 | } catch (e) { 53 | logger.error(`[${client.shard.ids}] ${e}`, interaction); 54 | } 55 | } 56 | } catch (e) { 57 | const me = interaction.guild.members.me; 58 | if (interaction.type === InteractionType.ApplicationCommand || interaction.type === InteractionType.MessageComponent) { 59 | logger.error(`[${client.shard.ids}] ${e}`, interaction); 60 | const channel = interaction.channel; 61 | if (channel.type !== ChannelType.GuildText || !channel.permissionsFor(me).has("SendMessages")) { 62 | logger.error(`[${client.shard.ids}] cannot send warning message to this channel`, interaction); 63 | return; 64 | } 65 | return InteractionUtils.replyOrFollowUp( 66 | interaction, 67 | `Oops, something went wrong. The best way to report this problem is to join our support server at ${hideLinkEmbed("https://go.av8.dev/support")}.` 68 | ); 69 | } 70 | } 71 | } 72 | 73 | private async setStatus(client: Client): Promise { 74 | const guildsCount = (await client.shard.fetchClientValues("guilds.cache.size")).reduce((acc: number, guildCount: number) => acc + guildCount, 0); 75 | const commandsCount = (await this._mongo.getCommandCounts()).total; 76 | await client.user.setActivity({ 77 | type: ActivityType.Watching, 78 | name: `${guildsCount} servers | ${commandsCount}+ commands used` 79 | }); 80 | } 81 | 82 | @RunEvery(30, minutes) 83 | private poll(client: Client): Promise { 84 | return this.setStatus(client); 85 | } 86 | 87 | private initDi(): void { 88 | DIService.allServices; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/api/controllers/impl/bot/shard/ShardController.ts: -------------------------------------------------------------------------------- 1 | import { ClassMiddleware, Controller, Get, Post } from "@overnightjs/core"; 2 | import type { MultipleShardSpawnOptions } from "discord.js"; 3 | import type { Request, Response } from "express"; 4 | import { StatusCodes } from "http-status-codes"; 5 | import { container } from "tsyringe"; 6 | 7 | import { AuthMiddleware } from "../../../../middlewares/AuthMiddleware.js"; 8 | import { ShardInfoService } from "../../../../service/ShardInfoService.js"; 9 | import { BaseController } from "../../../BaseController.js"; 10 | 11 | @Controller("shard") 12 | @ClassMiddleware(AuthMiddleware.isAdmin) 13 | export class ShardController extends BaseController { 14 | private readonly _shardInfoService: ShardInfoService; 15 | 16 | public constructor() { 17 | super(); 18 | this._shardInfoService = container.resolve(ShardInfoService); 19 | } 20 | 21 | @Get("shardIds") 22 | private shardIds(_req: Request, res: Response): Response { 23 | const shardIds = this._shardInfoService.shardIds; 24 | return super.ok(res, shardIds); 25 | } 26 | 27 | @Get("allShardInfo") 28 | private async allShardInfo(_req: Request, res: Response): Promise { 29 | try { 30 | const allShardInfo = await this._shardInfoService.getAllShardInfo(); 31 | return super.ok(res, allShardInfo); 32 | } catch (e) { 33 | return super.doError(res, e.message, StatusCodes.INTERNAL_SERVER_ERROR); 34 | } 35 | } 36 | 37 | @Get(":id([0-9]+)/shardInfo") 38 | private async shardInfo(req: Request, res: Response): Promise { 39 | try { 40 | const shardInfo = await this._shardInfoService.getShardInfo(Number(req.params.id)); 41 | return super.ok(res, shardInfo); 42 | } catch (e) { 43 | return super.doError(res, e.message, StatusCodes.BAD_REQUEST); 44 | } 45 | } 46 | 47 | @Post(":id([0-9]+)/kill") 48 | private kill(req: Request, res: Response): Response { 49 | try { 50 | const shardId = Number(req.params.id); 51 | this._shardInfoService.killShard(shardId); 52 | return super.ok(res, { 53 | success: `Shard ${shardId} has been killed` 54 | }); 55 | } catch (e) { 56 | return super.doError(res, e.message, StatusCodes.BAD_REQUEST); 57 | } 58 | } 59 | 60 | @Post(":id([0-9]+)/respawn") 61 | private async respawn(req: Request, res: Response): Promise { 62 | try { 63 | const shardId = Number(req.params.id); 64 | const process = await this._shardInfoService.respawn(shardId); 65 | return super.ok(res, { 66 | success: `Shard ${shardId} has been respawned`, 67 | pId: process.pid 68 | }); 69 | } catch (e) { 70 | return super.doError(res, e.message, StatusCodes.BAD_REQUEST); 71 | } 72 | } 73 | 74 | @Post("spawn") 75 | private async spawn(req: Request, res: Response): Promise { 76 | const body: MultipleShardSpawnOptions = req.body; 77 | try { 78 | const result = await this._shardInfoService.spawn(body); 79 | return super.ok(res, { 80 | success: `spawned ${result.length} shards`, 81 | info: result 82 | }); 83 | } catch (e) { 84 | return super.doError(res, e.message, StatusCodes.BAD_REQUEST); 85 | } 86 | } 87 | 88 | @Post("respawnAll") 89 | private async respawnAll(req: Request, res: Response): Promise { 90 | try { 91 | const result = await this._shardInfoService.respawnAll(); 92 | return super.ok(res, { 93 | success: `respawned ${result.length} shards`, 94 | info: result 95 | }); 96 | } catch (e) { 97 | return super.doError(res, e.message, StatusCodes.BAD_REQUEST); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/guards/RequiredBotPerms.ts: -------------------------------------------------------------------------------- 1 | import type { PermissionsType } from "@discordx/utilities"; 2 | import type { CommandInteraction } from "discord.js"; 3 | import { ChannelType, EmbedBuilder, GuildChannel, GuildMember, inlineCode, PermissionsBitField } from "discord.js"; 4 | import type { Client, GuardFunction, Next } from "discordx"; 5 | 6 | import logger from "../utils/LoggerFactory.js"; 7 | import { InteractionUtils } from "../utils/Utils.js"; 8 | 9 | /** 10 | * THis ensures the bot has the required permissions to execute the command 11 | * @param {{textChannel: PermissionsType, voice?: {perms: PermissionsType, enforce: boolean}}} permissions 12 | * @returns {GuardFunction} 13 | * @constructor 14 | */ 15 | export function RequiredBotPerms(permissions: { 16 | textChannel: PermissionsType; 17 | /** 18 | * Add voice permissions, setting this will do 3 things: 1. it will ensure the member calling this is in a voice channel. 2: it will enforce the bot has the correct permissions supplied in the voice channel the member is in. 3: it will ensure the voice channel is joinable and not full 19 | */ 20 | voice?: PermissionsType; 21 | }): GuardFunction { 22 | return async function (arg: CommandInteraction, client: Client, next: Next): Promise { 23 | const channel = arg.channel; 24 | if (!(channel instanceof GuildChannel) || !arg.inGuild()) { 25 | return next(); 26 | } 27 | const me = channel.guild.members.me; 28 | if (channel.type === ChannelType.GuildText) { 29 | if (!channel.permissionsFor(me).has(PermissionsBitField.Flags.SendMessages)) { 30 | logger.error(`[${client.shard.ids}] cannot send guard warning message to this channel`, arg); 31 | return; 32 | } 33 | } 34 | const perms = typeof permissions.textChannel === "function" ? await permissions.textChannel(arg) : permissions.textChannel; 35 | if (!channel.permissionsFor(me).has(perms)) { 36 | return InteractionUtils.replyOrFollowUp( 37 | arg, 38 | `AvBot doesn't have the required permissions to perform the action in this channel. Please enable "${perms.join(", ")}" under channel permissions for AvBot` 39 | ); 40 | } 41 | 42 | if (permissions.voice) { 43 | const voicePerms = typeof permissions.voice === "function" ? await permissions.voice(arg) : permissions.voice; 44 | const member = arg.member; 45 | if (member instanceof GuildMember) { 46 | const voiceChannel = member?.voice?.channel; 47 | if (voiceChannel) { 48 | if (!voiceChannel.permissionsFor(me).has(voicePerms)) { 49 | return InteractionUtils.replyOrFollowUp( 50 | arg, 51 | `AvBot doesn't have permissions to connect and/or to speak in your voice channel. Please enable "${voicePerms.join(", ")}" under channel permissions for AvBot.` 52 | ); 53 | } 54 | if (!voiceChannel.joinable) { 55 | const embed = new EmbedBuilder() 56 | .setTitle(inlineCode(arg.commandName)) 57 | .setColor("#ff0000") 58 | .setDescription(`${member}, AvBot is unable to join the voice channel as it is already full.`) 59 | .setFooter({ 60 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums` 61 | }) 62 | .setTimestamp(); 63 | return InteractionUtils.replyOrFollowUp(arg, { 64 | embeds: [embed] 65 | }); 66 | } 67 | } else { 68 | const embed = new EmbedBuilder() 69 | .setTitle(inlineCode(arg.commandName)) 70 | .setColor("#ff0000") 71 | .setDescription(`${member}, you need to join a voice channel first.`) 72 | .setFooter({ 73 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums` 74 | }) 75 | .setTimestamp(); 76 | return InteractionUtils.replyOrFollowUp(arg, { 77 | embeds: [embed] 78 | }); 79 | } 80 | } 81 | } 82 | return next(); 83 | }; 84 | } 85 | -------------------------------------------------------------------------------- /src/commands/Notam.ts: -------------------------------------------------------------------------------- 1 | import { Pagination, PaginationType } from "@discordx/pagination"; 2 | import { Category, NotBot } from "@discordx/utilities"; 3 | import { ApplicationCommandOptionType, AutocompleteInteraction, codeBlock, CommandInteraction, EmbedBuilder, inlineCode, time } from "discord.js"; 4 | import { Client, Discord, Guard, Slash, SlashOption } from "discordx"; 5 | import { injectable } from "tsyringe"; 6 | 7 | import { RequiredBotPerms } from "../guards/RequiredBotPerms.js"; 8 | import { AirportManager } from "../model/framework/manager/AirportManager.js"; 9 | import { Av8Manager } from "../model/framework/manager/Av8Manager.js"; 10 | import type { Notam } from "../model/Typeings.js"; 11 | import logger from "../utils/LoggerFactory.js"; 12 | import { InteractionUtils, ObjectUtil } from "../utils/Utils.js"; 13 | 14 | @Discord() 15 | @Category("Advisory") 16 | @injectable() 17 | export class Notams { 18 | public constructor(private _av8Manager: Av8Manager) {} 19 | 20 | @Slash({ 21 | description: "Gives you the active and upcoming NOTAMs for the chosen airport" 22 | }) 23 | @Guard( 24 | NotBot, 25 | RequiredBotPerms({ 26 | textChannel: ["EmbedLinks"] 27 | }) 28 | ) 29 | public async notam( 30 | @SlashOption({ 31 | name: "icao", 32 | autocomplete: (interaction: AutocompleteInteraction) => InteractionUtils.search(interaction, AirportManager), 33 | description: "What ICAO would you like the bot to give NOTAMs for?", 34 | type: ApplicationCommandOptionType.String, 35 | required: true 36 | }) 37 | icao: string, 38 | @SlashOption({ 39 | name: "upcoming", 40 | description: "Do you also want to get upcoming NOTAMs?", 41 | required: false 42 | }) 43 | upcoming: boolean, 44 | interaction: CommandInteraction, 45 | client: Client 46 | ): Promise { 47 | await interaction.deferReply(); 48 | icao = icao.toUpperCase(); 49 | 50 | try { 51 | const notams = await this._av8Manager.getNotams(icao, upcoming); 52 | const notamEmbeds: EmbedBuilder[] = []; 53 | for (const notam of notams) { 54 | const notamEmbed = new EmbedBuilder() 55 | .setTitle(`NOTAM: ${inlineCode(notam.id)}`) 56 | .setColor("#0099ff") 57 | .setFooter({ 58 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums • Source: Av8 API` 59 | }) 60 | .setDescription(codeBlock(notam.raw)) 61 | .addFields(ObjectUtil.singleFieldBuilder("Validity", this.getValidity(notam))) 62 | .setTimestamp(); 63 | 64 | notamEmbeds.push(notamEmbed); 65 | } 66 | await new Pagination( 67 | interaction, 68 | notamEmbeds.map((taf) => ({ 69 | embeds: [taf] 70 | })), 71 | { 72 | type: PaginationType.SelectMenu, 73 | placeholder: "Select NOTAM", 74 | pageText: notams.map((notam) => this.getLabel(notam)), 75 | showStartEnd: false, 76 | dispose: true 77 | } 78 | ).send(); 79 | } catch (err) { 80 | logger.error(`[${client.shard.ids}] ${err}`); 81 | const notamEmbed = new EmbedBuilder() 82 | .setTitle(`NOTAM: ${inlineCode(icao)}`) 83 | .setColor("#ff0000") 84 | .setFooter({ 85 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums • Source: Av8 API` 86 | }) 87 | .setDescription(`${interaction.member}, ${err.message}`) 88 | .setTimestamp(); 89 | return InteractionUtils.replyOrFollowUp(interaction, { 90 | embeds: [notamEmbed] 91 | }); 92 | } 93 | } 94 | 95 | private getValidity(notam: Notam): string { 96 | let validityStr = notam.validity.phrase; 97 | if (notam.from !== "PERMANENT") { 98 | if (notam.to === "PERMANENT") { 99 | validityStr += ` (Since ${time(notam.from.unix())})`; 100 | } else { 101 | validityStr += ` (${time(notam.from.unix())} to ${time(notam.to.unix())})`; 102 | } 103 | } 104 | 105 | return validityStr; 106 | } 107 | 108 | private getLabel(notam: Notam): string { 109 | let labelStr = `${notam.type}: ${notam.id}`; 110 | if (notam.from !== "PERMANENT") { 111 | if (notam.to === "PERMANENT") { 112 | labelStr += ` (${notam.from.format("MMM D, YYYY")} to PERM)`; 113 | } else { 114 | labelStr += ` (${notam.from.format("MMM D, YYYY")} to ${notam.to.format("MMM D, YYYY")})`; 115 | } 116 | } 117 | return labelStr; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/commands/Help.ts: -------------------------------------------------------------------------------- 1 | import { ICategory, NotBot } from "@discordx/utilities"; 2 | import { ActionRowBuilder, CommandInteraction, EmbedBuilder, inlineCode, InteractionResponse, SelectMenuBuilder, SelectMenuComponentOptionData, SelectMenuInteraction } from "discord.js"; 3 | import { Client, DApplicationCommand, Discord, Guard, MetadataStorage, SelectMenuComponent, Slash } from "discordx"; 4 | 5 | import { GuildOnly } from "../guards/GuildOnly.js"; 6 | import { InteractionUtils, ObjectUtil } from "../utils/Utils.js"; 7 | 8 | type CatCommand = DApplicationCommand & ICategory; 9 | 10 | @Discord() 11 | export class Help { 12 | private readonly _catMap: Map = new Map(); 13 | 14 | public constructor() { 15 | const commands: CatCommand[] = MetadataStorage.instance.applicationCommandSlashesFlat as CatCommand[]; 16 | for (const command of commands) { 17 | const { category } = command; 18 | if (!ObjectUtil.validString(category)) { 19 | continue; 20 | } 21 | if (this._catMap.has(category)) { 22 | this._catMap.get(category).push(command); 23 | } else { 24 | this._catMap.set(category, [command]); 25 | } 26 | } 27 | } 28 | 29 | @Slash({ 30 | description: "Get the description of all commands" 31 | }) 32 | @Guard(NotBot, GuildOnly) 33 | public help(interaction: CommandInteraction, client: Client): Promise { 34 | const embed = this.displayCategory(client); 35 | const selectMenu = this.getSelectDropdown(); 36 | return InteractionUtils.replyOrFollowUp(interaction, { 37 | embeds: [embed], 38 | components: [selectMenu] 39 | }); 40 | } 41 | 42 | private displayCategory(client: Client, category = "categories", pageNumber = 0): EmbedBuilder { 43 | if (category === "categories") { 44 | const embed = new EmbedBuilder() 45 | .setTitle(`${client.user.username} commands`) 46 | .setColor("#0099ff") 47 | .setDescription(`The items shown below are all the commands supported by this bot`) 48 | .setFooter({ 49 | text: `${client.user.username}` 50 | }) 51 | .setTimestamp(); 52 | for (const [cat] of this._catMap) { 53 | const description = `${cat} Commands`; 54 | embed.addFields(ObjectUtil.singleFieldBuilder(cat, description)); 55 | } 56 | return embed; 57 | } 58 | 59 | const commands = this._catMap.get(category); 60 | const chunks = this.chunk(commands, 24); 61 | const maxPage = chunks.length; 62 | const resultOfPage = chunks[pageNumber]; 63 | const embed = new EmbedBuilder() 64 | .setTitle(`${category} Commands:`) 65 | .setColor("#0099ff") 66 | .setFooter({ 67 | text: `${client.user.username} • Page ${pageNumber + 1} of ${maxPage}` 68 | }) 69 | .setTimestamp(); 70 | if (!resultOfPage) { 71 | return embed; 72 | } 73 | for (const item of resultOfPage) { 74 | const { description } = item; 75 | let fieldValue = "No description"; 76 | if (ObjectUtil.validString(description)) { 77 | fieldValue = description; 78 | } 79 | 80 | const name = ObjectUtil.validString(item.group) ? `/${item.group} ${item.name}` : `/${item.name}`; 81 | const nameToDisplay = inlineCode(name); 82 | embed.addFields(ObjectUtil.singleFieldBuilder(nameToDisplay, fieldValue, resultOfPage.length > 5)); 83 | } 84 | return embed; 85 | } 86 | 87 | private getSelectDropdown(defaultValue = "categories"): ActionRowBuilder { 88 | const optionsForEmbed: SelectMenuComponentOptionData[] = []; 89 | optionsForEmbed.push({ 90 | description: "View all categories", 91 | label: "Categories", 92 | value: "categories", 93 | default: defaultValue === "categories" 94 | }); 95 | for (const [cat] of this._catMap) { 96 | const description = `${cat} Commands`; 97 | optionsForEmbed.push({ 98 | description, 99 | label: cat, 100 | value: cat, 101 | default: defaultValue === cat 102 | }); 103 | } 104 | const selectMenu = new SelectMenuBuilder().addOptions(optionsForEmbed).setCustomId("help-category-selector"); 105 | return new ActionRowBuilder().addComponents(selectMenu); 106 | } 107 | 108 | @SelectMenuComponent({ 109 | id: "help-category-selector" 110 | }) 111 | private async selectCategory(interaction: SelectMenuInteraction, client: Client): Promise { 112 | const catToShow = interaction.values[0]; 113 | const categoryEmbed = await this.displayCategory(client, catToShow); 114 | const selectMenu = await this.getSelectDropdown(catToShow); 115 | return interaction.update({ 116 | embeds: [categoryEmbed], 117 | components: [selectMenu] 118 | }); 119 | } 120 | 121 | private chunk(array: T[], chunkSize: number): T[][] { 122 | const chunks: T[][] = []; 123 | for (let i = 0; i < array.length; i += chunkSize) { 124 | chunks.push(array.slice(i, i + chunkSize)); 125 | } 126 | return chunks; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | - Demonstrating empathy and kindness toward other people 14 | - Being respectful of differing opinions, viewpoints, and experiences 15 | - Giving and gracefully accepting constructive feedback 16 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | - Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | - The use of sexualized language or imagery, and sexual attention or advances of any kind 22 | - Trolling, insulting or derogatory comments, and personal or political attacks 23 | - Public or private harassment 24 | - Publishing others' private information, such as a physical or email address, without their explicit permission 25 | - Other conduct which could reasonably be considered inappropriate in a professional setting 26 | 27 | ## Enforcement Responsibilities 28 | 29 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 32 | 33 | ## Scope 34 | 35 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 36 | 37 | ## Enforcement 38 | 39 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [hi@av8.dev](mailto:hi@av8.dev). All complaints will be reviewed and investigated promptly and fairly. 40 | 41 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 42 | 43 | ## Enforcement Guidelines 44 | 45 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 46 | 47 | ### 1. Correction 48 | 49 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 50 | 51 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 52 | 53 | ### 2. Warning 54 | 55 | **Community Impact**: A violation through a single incident or series of actions. 56 | 57 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 58 | 59 | ### 3. Temporary Ban 60 | 61 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 62 | 63 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 64 | 65 | ### 4. Permanent Ban 66 | 67 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 68 | 69 | **Consequence**: A permanent ban from any sort of public interaction within the community. 70 | 71 | ## Attribution 72 | 73 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 74 | 75 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][mozilla coc]. 76 | 77 | For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][faq]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. 78 | 79 | [homepage]: https://www.contributor-covenant.org 80 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 81 | [mozilla coc]: https://github.com/mozilla/diversity 82 | [faq]: https://www.contributor-covenant.org/faq 83 | [translations]: https://www.contributor-covenant.org/translations 84 | -------------------------------------------------------------------------------- /src/api/service/ShardInfoService.ts: -------------------------------------------------------------------------------- 1 | import type { ChildProcess } from "child_process"; 2 | import { MultipleShardSpawnOptions, Shard, ShardingManager } from "discord.js"; 3 | import { singleton } from "tsyringe"; 4 | 5 | import TIME_UNIT from "../../enums/TIME_UNIT.js"; 6 | import { Main } from "../../main.js"; 7 | import type { DiscordServerInfo, ShardGuild, ShardInfo } from "../../model/Typeings.js"; 8 | import { ObjectUtil, Utils } from "../../utils/Utils.js"; 9 | 10 | @singleton() 11 | export class ShardInfoService { 12 | public constructor(private _shardingManager: ShardingManager) {} 13 | 14 | public get shardIds(): number[] { 15 | return this._shardingManager.shardList as number[]; 16 | } 17 | 18 | public async spawn( 19 | options: MultipleShardSpawnOptions = { 20 | amount: "auto", 21 | timeout: -1 22 | } 23 | ): Promise<(ShardInfo & { id: number })[]> { 24 | const spawnedShards = await this._shardingManager.spawn(options); 25 | if (options.delay > 0) { 26 | let sleepAmount: number; 27 | if (typeof options.amount === "number") { 28 | // add 500ms delay to each shard spawned 29 | sleepAmount = options.delay * options.amount + 500 * options.amount; 30 | } else { 31 | sleepAmount = options.delay + 500; 32 | } 33 | await Utils.sleep(sleepAmount); 34 | } else { 35 | await Utils.sleep(6000); 36 | } 37 | 38 | const retArr: (ShardInfo & { id: number })[] = []; 39 | for (const [id] of spawnedShards) { 40 | const shardInfo = await this.getShardInfo(id); 41 | retArr.push({ 42 | id, 43 | ...shardInfo 44 | }); 45 | } 46 | return retArr; 47 | } 48 | 49 | public async respawnAll(): Promise<(ShardInfo & { id: number })[]> { 50 | const spawnedShards = await this._shardingManager.respawnAll({ 51 | respawnDelay: 1000, 52 | shardDelay: 1000, 53 | timeout: -1 54 | }); 55 | const retArr: (ShardInfo & { id: number })[] = []; 56 | for (const [id] of spawnedShards) { 57 | const shardInfo = await this.getShardInfo(id); 58 | retArr.push({ 59 | id, 60 | ...shardInfo 61 | }); 62 | } 63 | return retArr; 64 | } 65 | 66 | public respawn(shardId: number): Promise { 67 | const shard = this.getShard(shardId); 68 | if (!shard.process) { 69 | throw new Error("Shard is dead and can not be restarted"); 70 | } 71 | return shard.respawn({ 72 | delay: 1000, 73 | timeout: -1 74 | }); 75 | } 76 | 77 | public killShard(shardId: number): void { 78 | const shard = this.getShard(shardId); 79 | if (!shard.process) { 80 | throw new Error("Shard is already dead"); 81 | } 82 | return shard.kill(); 83 | } 84 | 85 | public getAllShardInfo(): Promise<(ShardInfo & { id: number })[]> { 86 | const allShards = this.shardIds; 87 | const pArr = allShards.map((shardId) => this.getShardInfo(shardId)); 88 | return Promise.all(pArr).then((shards) => { 89 | const retArr: (ShardInfo & { id: number })[] = []; 90 | for (let i = 0; i < shards.length; i++) { 91 | const shard = shards[i]; 92 | retArr.push({ 93 | id: allShards[i], 94 | ...shard 95 | }); 96 | } 97 | return retArr; 98 | }); 99 | } 100 | 101 | public async getShardInfo(shardId?: number): Promise { 102 | const shard = this.getShard(shardId); 103 | const guildShard = await this.getShardGuilds([shard]); 104 | const servers = this.getDiscordServerInfo(guildShard); 105 | const shardUptime = Main.getShardUptime(shard); 106 | const uptime = ObjectUtil.timeToHuman(shardUptime, TIME_UNIT.milliseconds); 107 | return { 108 | uptime, 109 | servers 110 | }; 111 | } 112 | 113 | public getDiscordServerInfo(shards: ShardGuild[], limit = -1): DiscordServerInfo[] { 114 | const sorted = shards.sort((a, b) => b.memberCount - a.memberCount); 115 | if (limit < 0) { 116 | limit = sorted.length; 117 | } 118 | const retArr: DiscordServerInfo[] = []; 119 | for (let i = 0; i < limit; i++) { 120 | const shardGuild = sorted[i]; 121 | retArr.push({ 122 | name: shardGuild.name, 123 | iconUrl: shardGuild.iconURL, 124 | members: shardGuild.memberCount 125 | }); 126 | } 127 | return retArr; 128 | } 129 | 130 | public async getShardGuilds(shards?: Shard[]): Promise { 131 | const retArr: ShardGuild[] = []; 132 | const shardsToUse = ObjectUtil.isValidArray(shards) ? shards : [...this._shardingManager.shards.values()]; 133 | for (const shardRes of shardsToUse) { 134 | const promise = shardRes.fetchClientValue("guilds.cache") as Promise; 135 | const guilds: ShardGuild[] = await promise; 136 | if (ObjectUtil.isValidArray(guilds)) { 137 | retArr.push(...guilds); 138 | } 139 | } 140 | return retArr; 141 | } 142 | 143 | private getShard(shardId: number): Shard { 144 | const shard = this._shardingManager.shards.get(shardId); 145 | if (!shard) { 146 | throw new Error(`Shard with ID ${shardId} not found`); 147 | } 148 | return shard; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/commands/Nats.ts: -------------------------------------------------------------------------------- 1 | import { Category, NotBot } from "@discordx/utilities"; 2 | import dayjs from "dayjs"; 3 | import utc from "dayjs/plugin/utc.js"; 4 | import type { CommandInteraction } from "discord.js"; 5 | import { ActionRowBuilder, codeBlock, EmbedBuilder, inlineCode, Message, SelectMenuBuilder, SelectMenuComponentOptionData, SelectMenuInteraction, time } from "discord.js"; 6 | import { Client, Discord, Guard, SelectMenuComponent, Slash } from "discordx"; 7 | import { injectable } from "tsyringe"; 8 | 9 | import { RequiredBotPerms } from "../guards/RequiredBotPerms.js"; 10 | import { NatsManager } from "../model/framework/manager/NatsManager.js"; 11 | import logger from "../utils/LoggerFactory.js"; 12 | import { InteractionUtils, ObjectUtil } from "../utils/Utils.js"; 13 | 14 | @Discord() 15 | @Category("Advisory") 16 | @injectable() 17 | export class Nats { 18 | static { 19 | dayjs.extend(utc); 20 | } 21 | 22 | public constructor(private _natsManager: NatsManager) {} 23 | 24 | @Slash({ 25 | description: "Gives you the latest North Atlantic Tracks information" 26 | }) 27 | @Guard( 28 | NotBot, 29 | RequiredBotPerms({ 30 | textChannel: ["EmbedLinks"] 31 | }) 32 | ) 33 | public async nats(interaction: CommandInteraction, client: Client): Promise { 34 | await interaction.deferReply(); 35 | 36 | const natsEmbed = new EmbedBuilder() 37 | .setTitle("NATs") 38 | .setColor("#0099ff") 39 | .setFooter({ 40 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums • Source: Flight Plan Database` 41 | }) 42 | .setTimestamp(); 43 | try { 44 | const selectMenu = await this.getSelectDropdown(); 45 | await this.showEmbedBasedOnIdent(natsEmbed); 46 | return InteractionUtils.replyOrFollowUp(interaction, { 47 | embeds: [natsEmbed], 48 | components: [selectMenu] 49 | }); 50 | } catch (error) { 51 | logger.error(`[${client.shard.ids}] ${error}`); 52 | natsEmbed.setColor("#ff0000").setDescription(`${interaction.member}, ${error.message}`); 53 | } 54 | return InteractionUtils.replyOrFollowUp(interaction, { 55 | embeds: [natsEmbed] 56 | }); 57 | } 58 | 59 | @SelectMenuComponent({ 60 | id: "nats-selector" 61 | }) 62 | private async selectCategory(interaction: SelectMenuInteraction, client: Client): Promise { 63 | await interaction.deferUpdate(); 64 | const ident = interaction.values[0]; 65 | const dropdown = await this.getSelectDropdown(ident); 66 | const natsEmbed = new EmbedBuilder() 67 | .setTitle(`NAT: ${inlineCode(`Track ${ident}`)}`) 68 | .setColor("#0099ff") 69 | .setFooter({ 70 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums • Source: Flight Plan Database` 71 | }) 72 | .setTimestamp(); 73 | try { 74 | await this.showEmbedBasedOnIdent(natsEmbed, ident); 75 | return interaction.editReply({ 76 | embeds: [natsEmbed], 77 | components: [dropdown] 78 | }); 79 | } catch (error) { 80 | logger.error(`[${client.shard.ids}] ${error}`); 81 | natsEmbed.setColor("#ff0000").setDescription(`${interaction.member}, ${error.message}`); 82 | } 83 | } 84 | 85 | private async showEmbedBasedOnIdent(embed: EmbedBuilder, ident?: string): Promise { 86 | if (!ObjectUtil.validString(ident)) { 87 | const allTracks = await this._natsManager.getAllTracks(); 88 | ident = allTracks[0].ident; 89 | embed.setTitle(`NAT: ${inlineCode(`Track ${ident}`)}`); 90 | } 91 | const nat = await this._natsManager.getTrackInformation(ident); 92 | let route = ""; 93 | nat.route.nodes.forEach((node) => { 94 | route += `${node.ident} `; 95 | }); 96 | embed.addFields(ObjectUtil.singleFieldBuilder("Route", codeBlock(route))); 97 | if (nat.route.eastLevels.length > 0) { 98 | embed.addFields(ObjectUtil.singleFieldBuilder("East levels", `${nat.route.eastLevels.join(", ")}`)); 99 | } 100 | if (nat.route.westLevels.length > 0) { 101 | embed.addFields(ObjectUtil.singleFieldBuilder("West levels", `${nat.route.westLevels.join(", ")}`)); 102 | } 103 | 104 | const validFrom = `${dayjs(nat.validFrom).utc().format("HHmm[Z]")} (${time(dayjs(nat.validFrom).unix(), "R")})`; 105 | const validTo = `${dayjs(nat.validTo).utc().format("HHmm[Z]")} (${time(dayjs(nat.validTo).unix(), "R")})`; 106 | embed.addFields(ObjectUtil.singleFieldBuilder("Validity", `${validFrom} to ${validTo}`)); 107 | } 108 | 109 | private async getSelectDropdown(defaultValue?: string): Promise> { 110 | const optionsForEmbed: SelectMenuComponentOptionData[] = []; 111 | const nats = await this._natsManager.getAllTracks(); 112 | if (!ObjectUtil.validString(defaultValue)) { 113 | // when the dropdown is first created, just default it to the first item 114 | defaultValue = nats[0].ident; 115 | } 116 | for (const track of nats) { 117 | const trackName = `Track ${track.ident}`; 118 | const description = `${track.route.nodes[0].ident} - ${track.route.nodes[track.route.nodes.length - 1].ident}`; 119 | optionsForEmbed.push({ 120 | label: trackName, 121 | description, 122 | value: track.ident, 123 | default: track.ident === defaultValue 124 | }); 125 | } 126 | const selectMenu = new SelectMenuBuilder().addOptions(optionsForEmbed).setCustomId("nats-selector"); 127 | return new ActionRowBuilder().addComponents(selectMenu); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/commands/Taf.ts: -------------------------------------------------------------------------------- 1 | import { Pagination, PaginationType } from "@discordx/pagination"; 2 | import { Category, NotBot } from "@discordx/utilities"; 3 | import { ApplicationCommandOptionType, AutocompleteInteraction, codeBlock, CommandInteraction, EmbedBuilder, inlineCode } from "discord.js"; 4 | import { Client, Discord, Guard, Slash, SlashOption } from "discordx"; 5 | import { injectable } from "tsyringe"; 6 | 7 | import { RequiredBotPerms } from "../guards/RequiredBotPerms.js"; 8 | import { AirportManager } from "../model/framework/manager/AirportManager.js"; 9 | import { AvwxManager } from "../model/framework/manager/AvwxManager.js"; 10 | import logger from "../utils/LoggerFactory.js"; 11 | import { InteractionUtils, ObjectUtil } from "../utils/Utils.js"; 12 | 13 | @Discord() 14 | @Category("Advisory") 15 | @injectable() 16 | export class Taf { 17 | public constructor(private _avwxManager: AvwxManager) {} 18 | 19 | @Slash({ 20 | description: "Gives you the latest TAF for the chosen airport" 21 | }) 22 | @Guard( 23 | NotBot, 24 | RequiredBotPerms({ 25 | textChannel: ["EmbedLinks"] 26 | }) 27 | ) 28 | public async taf( 29 | @SlashOption({ 30 | name: "icao", 31 | autocomplete: (interaction: AutocompleteInteraction) => InteractionUtils.search(interaction, AirportManager), 32 | description: "What ICAO would you like the bot to give TAF for?", 33 | type: ApplicationCommandOptionType.String, 34 | required: true 35 | }) 36 | icao: string, 37 | @SlashOption({ 38 | name: "raw-only", 39 | description: "Gives you only the raw TAF for the chosen airport", 40 | required: false 41 | }) 42 | rawOnlyData: boolean, 43 | interaction: CommandInteraction, 44 | client: Client 45 | ): Promise { 46 | await interaction.deferReply(); 47 | icao = icao.toUpperCase(); 48 | 49 | const tafEmbed = new EmbedBuilder() 50 | .setTitle(`TAF: ${inlineCode(icao)}`) 51 | .setColor("#0099ff") 52 | .setFooter({ 53 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums • Source: AVWX` 54 | }) 55 | .setTimestamp(); 56 | try { 57 | const { raw, readable } = await this._avwxManager.getTaf(icao); 58 | 59 | if (rawOnlyData) { 60 | tafEmbed.setDescription(codeBlock(raw)); 61 | return InteractionUtils.replyOrFollowUp(interaction, { 62 | embeds: [tafEmbed] 63 | }); 64 | } 65 | 66 | if (readable.length < 600) { 67 | tafEmbed.addFields( 68 | { 69 | name: "Raw Report", 70 | value: "```" + raw + "```" 71 | }, 72 | { 73 | name: "Readable Report", 74 | value: readable 75 | } 76 | ); 77 | 78 | return InteractionUtils.replyOrFollowUp(interaction, { 79 | embeds: [tafEmbed] 80 | }); 81 | } 82 | 83 | const tafEmbeds: EmbedBuilder[] = []; 84 | let tempEmbed = new EmbedBuilder() 85 | .setTitle(`TAF: ${inlineCode(icao)}`) 86 | .setColor("#0099ff") 87 | .addFields(ObjectUtil.singleFieldBuilder("Raw Report", "```" + raw + "```")) 88 | .setFooter({ 89 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums • Source: AVWX` 90 | }) 91 | .setTimestamp(); 92 | 93 | tafEmbeds.push(tempEmbed); 94 | 95 | const readableList = readable.split(". "); 96 | let buffer = ""; 97 | 98 | for (let i = 0; i < readableList.length; i += 1) { 99 | const currentLine = `${readableList[i]}. `; 100 | buffer += currentLine; 101 | if (buffer.length > 600) { 102 | tempEmbed = new EmbedBuilder() 103 | .setTitle(`TAF: ${inlineCode(icao)}`) 104 | .setColor("#0099ff") 105 | .addFields(ObjectUtil.singleFieldBuilder(`Readable Report`, buffer)) 106 | .setFooter({ 107 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums • Source: AVWX` 108 | }) 109 | .setTimestamp(); 110 | 111 | tafEmbeds.push(tempEmbed); 112 | buffer = ""; 113 | } 114 | } 115 | 116 | tempEmbed = tafEmbed; 117 | if (buffer.length > 0) { 118 | tafEmbeds.push(tempEmbed.addFields(ObjectUtil.singleFieldBuilder(`Readable Report`, buffer))); 119 | } 120 | for (let i = 0; i < tafEmbeds.length; i += 1) { 121 | tafEmbeds[i].setFooter({ 122 | text: `${client.user.username} • Page ${i + 1} of ${tafEmbeds.length} • This is not a source for official briefing • Please use the appropriate forums • Source: AVWX` 123 | }); 124 | } 125 | 126 | await new Pagination( 127 | interaction, 128 | tafEmbeds.map((taf) => ({ 129 | embeds: [taf] 130 | })), 131 | { 132 | type: PaginationType.Button 133 | } 134 | ).send(); 135 | return; 136 | } catch (error) { 137 | logger.error(`[${client.shard.ids}] ${error}`); 138 | tafEmbed.setColor("#ff0000").setDescription(`${interaction.member}, ${error.message}`); 139 | } 140 | 141 | await InteractionUtils.replyOrFollowUp(interaction, { 142 | embeds: [tafEmbed] 143 | }); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/commands/Time.ts: -------------------------------------------------------------------------------- 1 | import { Category } from "@discordx/utilities"; 2 | import dayjs from "dayjs"; 3 | import timezone from "dayjs/plugin/timezone.js"; 4 | import utc from "dayjs/plugin/utc.js"; 5 | import type { CommandInteraction } from "discord.js"; 6 | import { ApplicationCommandOptionType, AutocompleteInteraction, EmbedBuilder, inlineCode } from "discord.js"; 7 | import { Client, Discord, Guard, Slash, SlashChoice, SlashGroup, SlashOption } from "discordx"; 8 | import { injectable } from "tsyringe"; 9 | 10 | import { RequiredBotPerms } from "../guards/RequiredBotPerms.js"; 11 | import { AirportManager } from "../model/framework/manager/AirportManager.js"; 12 | import { AvwxManager } from "../model/framework/manager/AvwxManager.js"; 13 | import { GeonamesManager } from "../model/framework/manager/GeonamesManager.js"; 14 | import logger from "../utils/LoggerFactory.js"; 15 | import { InteractionUtils } from "../utils/Utils.js"; 16 | 17 | @Discord() 18 | @Category("Time") 19 | @SlashGroup({ name: "time" }) 20 | @SlashGroup("time") 21 | @injectable() 22 | export class Time { 23 | static { 24 | dayjs.extend(utc); 25 | dayjs.extend(timezone); 26 | } 27 | 28 | public constructor(private _avwxManager: AvwxManager, private _geonamesManager: GeonamesManager) {} 29 | 30 | @Slash({ 31 | description: "Get the current zulu time" 32 | }) 33 | @Guard( 34 | RequiredBotPerms({ 35 | textChannel: ["EmbedLinks"] 36 | }) 37 | ) 38 | public async zulu(interaction: CommandInteraction, client: Client): Promise { 39 | await interaction.deferReply(); 40 | const localEmbed = new EmbedBuilder() 41 | .setTitle(`Zulu time`) 42 | .setColor("#0099ff") 43 | .setDescription(dayjs().utc().format("HHmm[Z]")) 44 | .setFooter({ 45 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums` 46 | }) 47 | .setTimestamp(); 48 | 49 | return InteractionUtils.replyOrFollowUp(interaction, { 50 | embeds: [localEmbed] 51 | }); 52 | } 53 | 54 | @Slash({ 55 | name: "convert", 56 | description: "Get the zulu to local or local to zulu time conversions for any chosen airport" 57 | }) 58 | @Guard( 59 | RequiredBotPerms({ 60 | textChannel: ["EmbedLinks"] 61 | }) 62 | ) 63 | public async time( 64 | @SlashChoice({ name: "Local to Zulu", value: "Zulu" }) 65 | @SlashChoice({ name: "Zulu to Local", value: "Local" }) 66 | @SlashOption({ 67 | name: "type", 68 | description: "Convert time from what to what?", 69 | type: ApplicationCommandOptionType.String, 70 | required: true 71 | }) 72 | type: "Zulu" | "Local", 73 | @SlashOption({ 74 | name: "icao", 75 | autocomplete: (interaction: AutocompleteInteraction) => InteractionUtils.search(interaction, AirportManager), 76 | description: "Convert time for which ICAO?", 77 | type: ApplicationCommandOptionType.String, 78 | required: true 79 | }) 80 | icao: string, 81 | @SlashOption({ 82 | name: "time", 83 | description: 'Enter local or zulu time as defined by your previous choices ("HHmm" format)', 84 | required: true 85 | }) 86 | time: string, 87 | interaction: CommandInteraction, 88 | client: Client 89 | ): Promise { 90 | await interaction.deferReply(); 91 | 92 | const opposite = type === "Local" ? "Zulu" : "Local"; 93 | const fromSuffix = type === "Local" ? "Z" : "hrs"; 94 | const toSuffix = type === "Local" ? "hrs" : "Z"; 95 | 96 | const localEmbed = new EmbedBuilder() 97 | .setTitle(`${type} Time`) 98 | .setColor("#0099ff") 99 | .setFooter({ 100 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums` 101 | }) 102 | .setTimestamp(); 103 | try { 104 | this.validateTime(time, opposite); 105 | } catch (e) { 106 | logger.error(`[${client.shard.ids}] ${e.message}`); 107 | localEmbed.setColor("#ff0000").setDescription(`${interaction.member}, ${e.message}`); 108 | return InteractionUtils.replyOrFollowUp(interaction, { 109 | embeds: [localEmbed] 110 | }); 111 | } 112 | try { 113 | const stationInfo = await this._avwxManager.getStation(icao); 114 | let timeString: string; 115 | const data = await this._geonamesManager.getTimezone(stationInfo.latitude.toString(), stationInfo.longitude.toString()); 116 | const [HH, MM] = [time.slice(0, 2), time.slice(2)]; 117 | if (type === "Local") { 118 | timeString = dayjs().utc().hour(Number.parseInt(HH)).minute(Number.parseInt(MM)).tz(data.timezoneId).format("HHmm"); 119 | } else { 120 | timeString = dayjs().utcOffset(data.gmtOffset).hour(Number.parseInt(HH)).minute(Number.parseInt(MM)).utc().format("HHmm"); 121 | } 122 | localEmbed.setTitle(`${type} time at ${inlineCode(icao)} when ${opposite.toLowerCase()} time is ${inlineCode(`${time}${fromSuffix}`)}`).setDescription(`${timeString}${toSuffix}`); 123 | } catch (error) { 124 | logger.error(`[${client.shard.ids}] ${error}`); 125 | localEmbed.setColor("#ff0000").setDescription(`${interaction.member}, ${error.message}`); 126 | } 127 | 128 | return InteractionUtils.replyOrFollowUp(interaction, { 129 | embeds: [localEmbed] 130 | }); 131 | } 132 | 133 | private validateTime(time: string, type: "Zulu" | "Local"): void { 134 | if (time.length !== 4) { 135 | throw new Error(`${type.toLowerCase()} time must be in HHmm format`); 136 | } 137 | const HH = time.slice(0, 2); 138 | const MM = time.slice(2); 139 | if (Number.isNaN(Number.parseInt(HH)) || Number.isNaN(Number.parseInt(MM))) { 140 | throw new Error("Invalid time, value must be a number"); 141 | } 142 | if (Number.parseInt(HH) > 23 || Number.parseInt(HH) < 0) { 143 | throw new Error("Invalid hours"); 144 | } 145 | if (Number.parseInt(MM) > 59 || Number.parseInt(MM) < 0) { 146 | throw new Error("Invalid minutes"); 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/commands/Station.ts: -------------------------------------------------------------------------------- 1 | import { Category } from "@discordx/utilities"; 2 | import { codeBlock } from "common-tags"; 3 | import type { CommandInteraction } from "discord.js"; 4 | import { ApplicationCommandOptionType, AutocompleteInteraction, EmbedBuilder, inlineCode } from "discord.js"; 5 | import { Client, Discord, Guard, Slash, SlashOption } from "discordx"; 6 | import accents from "remove-accents"; 7 | import { injectable } from "tsyringe"; 8 | 9 | import { RequiredBotPerms } from "../guards/RequiredBotPerms.js"; 10 | import { AirportManager } from "../model/framework/manager/AirportManager.js"; 11 | import { AvwxManager } from "../model/framework/manager/AvwxManager.js"; 12 | import type { AirportFrequency, Runway, Station } from "../model/Typeings.js"; 13 | import logger from "../utils/LoggerFactory.js"; 14 | import { InteractionUtils, ObjectUtil } from "../utils/Utils.js"; 15 | 16 | @Discord() 17 | @Category("IRL Aviation") 18 | @injectable() 19 | export class IcaoStation { 20 | public constructor(private _avwxManager: AvwxManager, private _airportManager: AirportManager) {} 21 | 22 | @Slash({ 23 | name: "station", 24 | description: "Gives you the station information for the chosen airport" 25 | }) 26 | @Guard( 27 | RequiredBotPerms({ 28 | textChannel: ["EmbedLinks"] 29 | }) 30 | ) 31 | public async icaoStation( 32 | @SlashOption({ 33 | name: "icao", 34 | autocomplete: (interaction: AutocompleteInteraction) => InteractionUtils.search(interaction, AirportManager), 35 | description: "What ICAO would you like the bot to give station information for?", 36 | type: ApplicationCommandOptionType.String, 37 | required: true 38 | }) 39 | icao: string, 40 | interaction: CommandInteraction, 41 | client: Client 42 | ): Promise { 43 | await interaction.deferReply(); 44 | icao = icao.toUpperCase(); 45 | 46 | const stationEmbed = new EmbedBuilder() 47 | .setTitle(`Station: ${inlineCode(icao)}`) 48 | .setColor("#0099ff") 49 | .setFooter({ 50 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums • Source: AVWX` 51 | }) 52 | .setTimestamp(); 53 | try { 54 | const station = await this._avwxManager.getStation(icao); 55 | const frequenciesData = await this._airportManager.getAirportFrequencies(icao); 56 | stationEmbed.addFields( 57 | { 58 | name: "IATA", 59 | value: station.iata || "Unknown", 60 | inline: true 61 | }, 62 | { 63 | name: "GPS", 64 | value: station.gps || "Unknown", 65 | inline: true 66 | }, 67 | { 68 | name: "Name", 69 | value: station.name ? accents.remove(station.name) : "Unknown", 70 | inline: true 71 | }, 72 | { 73 | name: "City", 74 | value: station.city ? accents.remove(station.city) : "Unknown", 75 | inline: true 76 | }, 77 | { 78 | name: "Country", 79 | value: station.country ? accents.remove(station.country) : "Unknown", 80 | inline: true 81 | }, 82 | { 83 | name: "Type", 84 | value: station.type.split("_")[0] || "Unknown", 85 | inline: true 86 | }, 87 | { 88 | name: "Latitude", 89 | value: station.latitude.toString() || "Unknown", 90 | inline: true 91 | }, 92 | { 93 | name: "Longitude", 94 | value: station.longitude.toString() || "Unknown", 95 | inline: true 96 | }, 97 | { 98 | name: "Elevation", 99 | value: station.elevation_ft ? `${station.elevation_ft} ft` : "Unknown", 100 | inline: true 101 | }, 102 | { 103 | name: "Runways", 104 | value: station.runways ? codeBlock(this.getRunwaysStr(station.runways)) : "Unknown", 105 | inline: false 106 | }, 107 | { 108 | name: "Frequencies", 109 | value: frequenciesData.length > 0 ? codeBlock(this.getFrequenciesStr(frequenciesData.frequencies)) : "Unknown", 110 | inline: false 111 | }, 112 | { 113 | name: "More Info", 114 | value: this.getLinks(station), 115 | inline: false 116 | } 117 | ); 118 | } catch (error) { 119 | logger.error(`[${client.shard.ids}] ${error}`); 120 | stationEmbed.setColor("#ff0000").setDescription(`${interaction.member}, ${error.message}`); 121 | } 122 | 123 | return InteractionUtils.replyOrFollowUp(interaction, { 124 | embeds: [stationEmbed] 125 | }); 126 | } 127 | 128 | private getRunwaysStr(runways: Runway[]): string { 129 | const stre = runways 130 | .map((rw) => { 131 | if (rw.length_ft !== 0 && rw.width_ft !== 0) { 132 | return `${rw.ident1}-${rw.ident2}: Length - ${rw.length_ft} ft, Width - ${rw.width_ft} ft`; 133 | } 134 | return `${rw.ident1}-${rw.ident2}: Length - NA, Width - NA`; 135 | }) 136 | .join("\n"); 137 | return ObjectUtil.validString(stre) ? stre : "Unknown"; 138 | } 139 | 140 | private getFrequenciesStr(frequencies: AirportFrequency[]): string { 141 | return frequencies 142 | .map((freq) => { 143 | const description = freq.type + (freq.description ? ` (${freq.description})` : ""); 144 | return `${description}: ${Number(freq.frequency_mhz).toFixed(3).toString()}`; 145 | }) 146 | .join("\n"); 147 | } 148 | 149 | private getLinks(station: Station): string { 150 | let links = ""; 151 | if (station.website) { 152 | links += `Official Website: ${station.website}`; 153 | if (station.wiki) { 154 | links += `\nWikipedia: ${station.wiki}`; 155 | } 156 | } else if (station.wiki) { 157 | links += `\nWikipedia: ${station.wiki}`; 158 | } 159 | return ObjectUtil.validString(links) ? links : "None"; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/model/framework/manager/AvwxManager.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosResponse } from "axios"; 2 | import dayjs from "dayjs"; 3 | import utc from "dayjs/plugin/utc.js"; 4 | import { bold, time } from "discord.js"; 5 | import accents from "remove-accents"; 6 | import { singleton } from "tsyringe"; 7 | 8 | import logger from "../../../utils/LoggerFactory.js"; 9 | import { ObjectUtil } from "../../../utils/Utils.js"; 10 | import type { MetarInfo, Station, TafInfo } from "../../Typeings.js"; 11 | import { Property } from "../decorators/Property.js"; 12 | import { AbstractRequestEngine } from "../engine/impl/AbstractRequestEngine.js"; 13 | 14 | @singleton() 15 | export class AvwxManager extends AbstractRequestEngine { 16 | static { 17 | dayjs.extend(utc); 18 | } 19 | 20 | @Property("AVWX_TOKEN") 21 | private static readonly avwxToken: string; 22 | 23 | public constructor() { 24 | super("https://avwx.rest/api/", { 25 | headers: { 26 | Authorization: AvwxManager.avwxToken 27 | } 28 | }); 29 | } 30 | 31 | public async getStation(icao: string): Promise { 32 | try { 33 | const response = await this.api.get(`/station/${icao}`); 34 | 35 | await this.validateResponse(response, `no station available at the moment near ${icao}`); 36 | 37 | return response.data; 38 | } catch (error) { 39 | logger.error(`[x] ${error}`); 40 | return Promise.reject(error); 41 | } 42 | } 43 | 44 | public async getTaf(icao: string): Promise { 45 | try { 46 | const response = await this.api.get(`/taf/${icao}?options=info,translate,speech`); 47 | 48 | await this.validateResponse(response, `no station available at the moment near ${icao}`); 49 | 50 | const taf: Record = response.data; 51 | 52 | let readable = ""; 53 | readable += `${bold("Station : ")} `; 54 | 55 | if (taf.info.icao) { 56 | readable += `${taf.info.icao}`; 57 | } else { 58 | readable += `${taf.station}`; 59 | } 60 | 61 | const station = this.getStationName(taf); 62 | 63 | if (ObjectUtil.validString(station)) { 64 | readable += ` (${station})`; 65 | } 66 | 67 | readable += "\n"; 68 | 69 | const observedTime = dayjs(taf.time.dt).utc(); 70 | readable += `${bold("Observed at : ")} ${observedTime.format("HHmm[Z]")} (${time(observedTime.unix(), "R")}) \n`; 71 | 72 | readable += `${bold("Report : ")} ${taf.speech}`; 73 | 74 | return { 75 | raw: taf.raw, 76 | readable, 77 | speech: taf.speech 78 | }; 79 | } catch (error) { 80 | logger.error(`[x] ${error}`); 81 | return Promise.reject(error); 82 | } 83 | } 84 | 85 | public async getMetar(icao: string): Promise { 86 | try { 87 | const response = await this.api.get(`/metar/${icao}?options=info,translate,speech`); 88 | 89 | await this.validateResponse(response, `no station available at the moment near ${icao}`); 90 | 91 | const metar = response.data; 92 | let readable = ""; 93 | readable += `${bold("Station : ")} `; 94 | 95 | if (metar.info.icao) { 96 | readable += `${metar.info.icao}`; 97 | } else { 98 | readable += `${metar.station}`; 99 | } 100 | 101 | const station = this.getStationName(metar); 102 | 103 | if (ObjectUtil.validString(station)) { 104 | readable += ` (${station})`; 105 | } 106 | 107 | readable += "\n"; 108 | 109 | const observedTime = dayjs(metar.time.dt).utc(); 110 | readable += `${bold("Observed at : ")} ${observedTime.format("HHmm[Z]")} (${time(observedTime.unix(), "R")}) \n`; 111 | 112 | if (metar.translate.wind) { 113 | readable += `${bold("Wind : ")} ${metar.translate.wind} \n`; 114 | } 115 | 116 | if (metar.translate.visibility) { 117 | readable += `${bold("Visibility : ")} ${metar.translate.visibility} \n`; 118 | } 119 | 120 | if (metar.translate.temperature) { 121 | readable += `${bold("Temperature : ")} ${metar.translate.temperature} \n`; 122 | } 123 | 124 | if (metar.translate.dewpoint) { 125 | readable += `${bold("Dew Point : ")} ${metar.translate.dewpoint} \n`; 126 | } 127 | 128 | if (metar.translate.altimeter) { 129 | readable += `${bold("Altimeter : ")} ${metar.translate.altimeter} \n`; 130 | } 131 | 132 | if (metar.translate.clouds) { 133 | readable += `${bold("Clouds : ")} ${metar.translate.clouds} \n`; 134 | } 135 | 136 | if (metar.translate.other) { 137 | readable += `${bold("Weather Phenomena : ")} ${metar.translate.other}\n`; 138 | } 139 | 140 | if (metar.flight_rules) { 141 | readable += `${bold("Flight Rules : ")} ${metar.flight_rules}`; 142 | } 143 | 144 | return { 145 | raw: metar.raw, 146 | readable, 147 | speech: metar.speech 148 | }; 149 | } catch (error) { 150 | logger.error(`[x] ${error}`); 151 | return Promise.reject(error); 152 | } 153 | } 154 | 155 | private getStationName({ info }: Record): string { 156 | let station = ""; 157 | if (info.name || info.city) { 158 | if (info.name) { 159 | try { 160 | station += `${accents.remove(info.name)}`; 161 | if (info.city) { 162 | try { 163 | station += `, ${accents.remove(info.city)}`; 164 | } catch (err) { 165 | logger.error(`[x] ${err}`); 166 | } 167 | } 168 | } catch (error) { 169 | logger.error(`[x] ${error}`); 170 | if (info.city) { 171 | try { 172 | station += `${accents.remove(info.city)}`; 173 | } catch (err) { 174 | logger.error(`[x] ${err}`); 175 | } 176 | } 177 | } 178 | } 179 | } 180 | return station; 181 | } 182 | 183 | private validateResponse(response: AxiosResponse, defaultError: string): Promise { 184 | if (response.status !== 200) { 185 | return Promise.reject(new Error(response?.data?.error ?? defaultError)); 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/utils/Utils.ts: -------------------------------------------------------------------------------- 1 | import dayjs, { Dayjs } from "dayjs"; 2 | import utc from "dayjs/plugin/utc.js"; 3 | import type { APIEmbedField, AutocompleteInteraction, EmbedData, InteractionReplyOptions, MessageComponentInteraction } from "discord.js"; 4 | import { CommandInteraction, EmbedBuilder, WebhookClient } from "discord.js"; 5 | import { container } from "tsyringe"; 6 | import type constructor from "tsyringe/dist/typings/types/constructor"; 7 | 8 | import TIME_UNIT from "../enums/TIME_UNIT.js"; 9 | import { Property } from "../model/framework/decorators/Property.js"; 10 | import type { ISearchBase, SearchBase } from "../model/framework/ISearchBase.js"; 11 | import type { NODE_ENV } from "../model/Typeings.js"; 12 | import logger from "./LoggerFactory.js"; 13 | 14 | export class Utils { 15 | public static sleep(ms: number): Promise { 16 | return new Promise((resolve) => { 17 | setTimeout(resolve, ms); 18 | }); 19 | } 20 | } 21 | 22 | export class ObjectUtil { 23 | static { 24 | dayjs.extend(utc); 25 | } 26 | 27 | public static get dayJsAsUtc(): Dayjs { 28 | return dayjs(); 29 | } 30 | 31 | /** 32 | * Ensures value(s) strings and has a size after trim 33 | * @param strings 34 | * @returns {boolean} 35 | */ 36 | public static validString(...strings: Array): boolean { 37 | if (strings.length === 0) { 38 | return false; 39 | } 40 | for (const currString of strings) { 41 | if (typeof currString !== "string") { 42 | return false; 43 | } 44 | if (currString.length === 0) { 45 | return false; 46 | } 47 | if (currString.trim().length === 0) { 48 | return false; 49 | } 50 | } 51 | return true; 52 | } 53 | 54 | /** 55 | * ensures value is an array and has at least 1 item in it 56 | * @param array 57 | * @returns {array is any[]} 58 | */ 59 | public static isValidArray(array: any): array is any[] { 60 | return Array.isArray(array) && array.length > 0; 61 | } 62 | 63 | /** 64 | * Assert argument is an object, and it has more than one key 65 | * @param obj 66 | * @returns {obj is Record} 67 | */ 68 | public static isValidObject(obj: unknown): obj is Record { 69 | return typeof obj === "object" && obj !== null && obj !== undefined && Object.keys(obj).length > 0; 70 | } 71 | 72 | public static convertToMilli(value: number, unit: TIME_UNIT): number { 73 | switch (unit) { 74 | case TIME_UNIT.seconds: 75 | return value * 1000; 76 | case TIME_UNIT.minutes: 77 | return value * 60000; 78 | case TIME_UNIT.hours: 79 | return value * 3600000; 80 | case TIME_UNIT.days: 81 | return value * 86400000; 82 | case TIME_UNIT.weeks: 83 | return value * 604800000; 84 | case TIME_UNIT.months: 85 | return value * 2629800000; 86 | case TIME_UNIT.years: 87 | return value * 31556952000; 88 | case TIME_UNIT.decades: 89 | return value * 315569520000; 90 | } 91 | } 92 | 93 | public static timeToHuman(value: number, timeUnit: TIME_UNIT = TIME_UNIT.milliseconds): string { 94 | let seconds: number; 95 | if (timeUnit === TIME_UNIT.milliseconds) { 96 | seconds = Math.round(value / 1000); 97 | } else if (timeUnit !== TIME_UNIT.seconds) { 98 | seconds = Math.round(ObjectUtil.convertToMilli(value, timeUnit) / 1000); 99 | } else { 100 | seconds = Math.round(value); 101 | } 102 | if (Number.isNaN(seconds)) { 103 | throw new Error("Unknown error"); 104 | } 105 | const levels: [number, string][] = [ 106 | [Math.floor(seconds / 31536000), "years"], 107 | [Math.floor((seconds % 31536000) / 86400), "days"], 108 | [Math.floor(((seconds % 31536000) % 86400) / 3600), "hours"], 109 | [Math.floor((((seconds % 31536000) % 86400) % 3600) / 60), "minutes"], 110 | [(((seconds % 31536000) % 86400) % 3600) % 60, "seconds"] 111 | ]; 112 | let returnText = ""; 113 | 114 | for (let i = 0, max = levels.length; i < max; i++) { 115 | if (levels[i][0] === 0) { 116 | continue; 117 | } 118 | returnText += ` ${levels[i][0]} ${levels[i][0] === 1 ? levels[i][1].substr(0, levels[i][1].length - 1) : levels[i][1]}`; 119 | } 120 | return returnText.trim(); 121 | } 122 | 123 | public static singleFieldBuilder(name: string, value: string, inline = false): [APIEmbedField] { 124 | return [ 125 | { 126 | name, 127 | value, 128 | inline 129 | } 130 | ]; 131 | } 132 | } 133 | 134 | export class InteractionUtils { 135 | @Property("NODE_ENV") 136 | private static readonly environment: NODE_ENV; 137 | 138 | public static async replyOrFollowUp(interaction: CommandInteraction | MessageComponentInteraction, replyOptions: (InteractionReplyOptions & { ephemeral?: boolean }) | string): Promise { 139 | // if interaction is already replied 140 | if (interaction.replied) { 141 | await interaction.followUp(replyOptions); 142 | return; 143 | } 144 | 145 | // if interaction is deferred but not replied 146 | if (interaction.deferred) { 147 | await interaction.editReply(replyOptions); 148 | return; 149 | } 150 | 151 | // if interaction is not handled yet 152 | await interaction.reply(replyOptions); 153 | } 154 | 155 | public static async search>(interaction: AutocompleteInteraction, contextHandler: constructor): Promise { 156 | const handler = container.resolve(contextHandler); 157 | const searchResults = await handler.search(interaction); 158 | if (ObjectUtil.isValidArray(searchResults)) { 159 | const responseMap = searchResults.map((searchResult) => { 160 | return { 161 | name: searchResult.name, 162 | value: searchResult.value 163 | }; 164 | }); 165 | return interaction.respond(responseMap); 166 | } 167 | return interaction.respond([]); 168 | } 169 | 170 | public static async sendWebhookMessage(embedOptions: EmbedData, webhookClient: WebhookClient): Promise { 171 | if (!webhookClient) { 172 | return; 173 | } 174 | try { 175 | const embed = new EmbedBuilder({ timestamp: new Date().toISOString(), ...embedOptions }); 176 | await webhookClient.send({ 177 | embeds: [embed], 178 | username: ["AvBot", this.environment === "development" ? "[ALPHA]" : ""].join(" "), 179 | avatarURL: `https://bot.av8.dev/img/logo_${this.environment}.png` 180 | }); 181 | } catch (error) { 182 | logger.error(`Failed to send webhook message: "${JSON.stringify(embedOptions)}"`, error); 183 | } 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/commands/Flight.ts: -------------------------------------------------------------------------------- 1 | import { Category, RateLimit, TIME_UNIT } from "@discordx/utilities"; 2 | import type { CommandInteraction } from "discord.js"; 3 | import { EmbedBuilder, inlineCode } from "discord.js"; 4 | import { Client, Discord, Guard, Slash, SlashOption } from "discordx"; 5 | import { injectable } from "tsyringe"; 6 | 7 | import { GuildOnly } from "../guards/GuildOnly.js"; 8 | import { PremiumGuild } from "../guards/PremiumGuild.js"; 9 | import { RequiredBotPerms } from "../guards/RequiredBotPerms.js"; 10 | import { AeroDataBoxManager } from "../model/framework/manager/AeroDataBoxManager.js"; 11 | import { AirportDataManager } from "../model/framework/manager/AirportDataManager.js"; 12 | import { AviationStackManager } from "../model/framework/manager/AviationStackManager.js"; 13 | import { OpenSkyManager } from "../model/framework/manager/OpenSkyManager.js"; 14 | import logger from "../utils/LoggerFactory.js"; 15 | import { InteractionUtils, ObjectUtil } from "../utils/Utils.js"; 16 | 17 | @Discord() 18 | @Category("IRL Aviation") 19 | @injectable() 20 | export class Flight { 21 | public constructor( 22 | private _openSkyManager: OpenSkyManager, 23 | private _aviationStackManager: AviationStackManager, 24 | private _aeroDataBoxManager: AeroDataBoxManager, 25 | private _airportDataManager: AirportDataManager 26 | ) {} 27 | 28 | @Slash({ 29 | description: "[PREMIUM] Gives you the information for the chosen call sign of the real life flight" 30 | }) 31 | @Guard( 32 | GuildOnly, 33 | PremiumGuild, 34 | RateLimit(TIME_UNIT.seconds, 90, { 35 | message: `Your command is being rate limited! Try again after {until}.`, 36 | ephemeral: true 37 | }), 38 | RequiredBotPerms({ 39 | textChannel: ["EmbedLinks"] 40 | }) 41 | ) 42 | public async flight( 43 | @SlashOption({ 44 | name: "call-sign", 45 | description: "What call sign would you like the bot to give information for?", 46 | required: true 47 | }) 48 | callSign: string, 49 | interaction: CommandInteraction, 50 | client: Client 51 | ): Promise { 52 | await interaction.deferReply(); 53 | callSign = callSign.toUpperCase(); 54 | 55 | const liveEmbed = new EmbedBuilder() 56 | .setTitle(`Flight: ${inlineCode(callSign)}`) 57 | .setColor("#0099ff") 58 | .setFooter({ 59 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums • Source: The OpenSky Network API | AviationStack | AeroDataBox | AirportData` 60 | }) 61 | .setTimestamp(); 62 | let icao24 = null; 63 | try { 64 | const flightInfo = await this._openSkyManager.getFlightInfo(callSign); 65 | icao24 = flightInfo.icao24; 66 | liveEmbed 67 | .setTitle(`Flight: ${inlineCode(callSign)} (Track on OpenSky Network)`) 68 | .setURL(`https://opensky-network.org/network/explorer?icao24=${icao24}&callsign=${callSign}`) 69 | .addFields([ 70 | { 71 | name: "Callsign", 72 | value: flightInfo.callsign, 73 | inline: true 74 | }, 75 | { 76 | name: "Ground Speed", 77 | value: flightInfo.velocity, 78 | inline: true 79 | }, 80 | { 81 | name: "Heading", 82 | value: flightInfo.true_track, 83 | inline: true 84 | }, 85 | { 86 | name: "Altitude", 87 | value: flightInfo.geo_altitude, 88 | inline: true 89 | }, 90 | { 91 | name: "Climb Rate", 92 | value: flightInfo.vertical_rate, 93 | inline: true 94 | }, 95 | { 96 | name: "Squawk", 97 | value: flightInfo.squawk, 98 | inline: true 99 | }, 100 | { 101 | name: "Country of Origin", 102 | value: flightInfo.origin_country, 103 | inline: true 104 | }, 105 | { 106 | name: "ICAO Address", 107 | value: flightInfo.icao24, 108 | inline: true 109 | } 110 | ]); 111 | } catch (error) { 112 | logger.error(`[${client.shard.ids}] ${error}`); 113 | liveEmbed.setColor("#ff0000").setDescription(`${interaction.member}, ${error.message}`); 114 | return InteractionUtils.replyOrFollowUp(interaction, { 115 | embeds: [liveEmbed] 116 | }); 117 | } 118 | 119 | try { 120 | const flightInfo = await this._aviationStackManager.getFlightInfo(callSign); 121 | liveEmbed.addFields([ 122 | { 123 | name: "Departure", 124 | value: flightInfo.departure ? flightInfo.departure.icao + (flightInfo.departure.airport ? ` | ${flightInfo.departure.airport}` : "") : "Unknown", 125 | inline: true 126 | }, 127 | { 128 | name: "Arrival", 129 | value: flightInfo.arrival.icao ? flightInfo.arrival.icao + (flightInfo.arrival.airport ? ` | ${flightInfo.arrival.airport}` : "") : "Unknown", 130 | inline: true 131 | } 132 | ]); 133 | } catch (error) { 134 | logger.error(`[${client.shard.ids}] ${error}`); 135 | } 136 | 137 | try { 138 | const aircraftInfo = await this._aeroDataBoxManager.getAircraftInfo(icao24); 139 | 140 | liveEmbed.addFields([ 141 | { 142 | name: "Airline", 143 | value: aircraftInfo.airlineName ? aircraftInfo.airlineName : "Unknown", 144 | inline: true 145 | }, 146 | { 147 | name: "Aircraft", 148 | value: aircraftInfo.typeName ? aircraftInfo.typeName : "Unknown", 149 | inline: true 150 | }, 151 | { 152 | name: "Registration", 153 | value: aircraftInfo.reg ? aircraftInfo.reg : "Unknown", 154 | inline: true 155 | } 156 | ]); 157 | } catch (error) { 158 | logger.error(`[${client.shard.ids}] ${error}`); 159 | } 160 | 161 | try { 162 | const aircraftImage = await this._airportDataManager.getAircraftImage(icao24); 163 | 164 | liveEmbed.setImage(aircraftImage.image).addFields(ObjectUtil.singleFieldBuilder("Image Credits", `[${aircraftImage.photographer}](${aircraftImage.link})`)); 165 | } catch (error) { 166 | logger.error(`[${client.shard.ids}] ${error}`); 167 | } 168 | 169 | return InteractionUtils.replyOrFollowUp(interaction, { 170 | embeds: [liveEmbed] 171 | }); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | AvBot logo 3 |

4 | 5 |

AvBot

6 |

Aviation enthusiast's friendly neighborhood bot

7 | 8 |

9 | Bot Status 10 |

11 | 12 |

13 | Version 14 | License 15 | Contributor Covenant 16 |

17 | 18 |

19 | CircleCI Build Status 20 | Depfu - Summary of all dependencies 21 | GitHub Issues 22 | GitHub Pull Requests 23 |

24 | 25 |

26 | DeepSource Active Issues 27 | DeepSource Resolved Issues 28 | Snyk Vulnerabilities 29 |

30 | 31 |

32 | CircleCI Build Status Insights 33 |

34 | 35 |

36 | Discord Support Server 37 |

38 | 39 |

40 | DigitalOcean Referral Badge 41 |

42 | 43 | ## Commands 44 | 45 | | Slash | Description | 46 | | --------------- | ---------------------------------------------------------------------------------------------- | 47 | | `/metar` | Get the latest METAR (Meteorological Terminal Aviation Routine Weather Report) for any airport | 48 | | `/taf` | Get the latest TAF (Terminal Aerodrome Forecast) for any airport | 49 | | `/notam` | Get the active and upcoming NOTAMs (Notice to Air Missions) for any airport | 50 | | `/station` | Get the station information for any airport | 51 | | `/atis text` | Get the live ATIS (Automatic Terminal Information Service) for any airport as text | 52 | | `/atis voice` | Get the live ATIS (Automatic Terminal Information Service) for any airport as voice | 53 | | `/flight` | Get the flight information for a real life flight | 54 | | `/ivao` | Get the information for a flight or an ATC on the IVAO network | 55 | | `/vatsim` | Get the information for a flight or an ATC on the VATSIM network | 56 | | `/poscon` | Get the information for a flight or an ATC on the POSCON network | 57 | | `/nats` | Get the information for the latest active North Atlantic Tracks | 58 | | `/time zulu` | Get the current zulu time | 59 | | `/time convert` | Get the zulu to local or local to zulu time conversions for any airport | 60 | | `/help` | Get the description of all commands | 61 | | `/info` | Provides information about AvBot, and links for adding the bot and joining the support server | 62 | | `/ping` | Checks the AvBot's ping to the Discord server | 63 | 64 | ## Contributing 65 | 66 | Pull requests are welcome. See the [CONTRIBUTING](./CONTRIBUTING.md) file for more instructions. For major changes, please open an issue first to discuss what you would like to change. 67 | 68 | ## Contributors ✨ 69 | 70 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 |

Rahul Singh

🚇 💻 📖

Joshua T.

💻

Nathan Dawson

💻

ransbachm

💻

Victorique

🚇 💻 🚧

Maher Abaza

💻
85 | 86 | 87 | 88 | 89 | 90 | 91 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 92 | -------------------------------------------------------------------------------- /src/commands/Vatsim.ts: -------------------------------------------------------------------------------- 1 | import { Category, NotBot } from "@discordx/utilities"; 2 | import { ApplicationCommandOptionType, AutocompleteInteraction, codeBlock, CommandInteraction, EmbedBuilder, inlineCode } from "discord.js"; 3 | import { Client, Discord, Guard, Slash, SlashChoice, SlashOption } from "discordx"; 4 | import { injectable } from "tsyringe"; 5 | 6 | import { GuildOnly } from "../guards/GuildOnly.js"; 7 | import { RequiredBotPerms } from "../guards/RequiredBotPerms.js"; 8 | import { AirportManager } from "../model/framework/manager/AirportManager.js"; 9 | import { VatsimManager } from "../model/framework/manager/VatsimManager.js"; 10 | import type { VatsimAtc, VatsimPilot } from "../model/Typeings.js"; 11 | import logger from "../utils/LoggerFactory.js"; 12 | import { InteractionUtils } from "../utils/Utils.js"; 13 | 14 | @Discord() 15 | @Category("Flight Sim Network") 16 | @injectable() 17 | export class Vatsim { 18 | public constructor(private _vatsimManager: VatsimManager, private _airportManager: AirportManager) {} 19 | 20 | @Slash({ 21 | description: "Gives you the information for the chosen call sign on the VATSIM network" 22 | }) 23 | @Guard( 24 | NotBot, 25 | RequiredBotPerms({ 26 | textChannel: ["EmbedLinks"] 27 | }), 28 | GuildOnly 29 | ) 30 | public async vatsim( 31 | @SlashChoice("atc", "pilot") 32 | @SlashOption({ 33 | name: "type", 34 | description: "What type of client would you like the bot to give information for?", 35 | type: ApplicationCommandOptionType.String, 36 | required: true 37 | }) 38 | type: "atc" | "pilot", 39 | @SlashOption({ 40 | name: "call-sign", 41 | description: "What call sign would you like the bot to give information for?", 42 | autocomplete: (interaction: AutocompleteInteraction) => InteractionUtils.search(interaction, VatsimManager), 43 | type: ApplicationCommandOptionType.String, 44 | required: true 45 | }) 46 | callSign: string, 47 | interaction: CommandInteraction, 48 | client: Client 49 | ): Promise { 50 | await interaction.deferReply(); 51 | callSign = callSign.toUpperCase(); 52 | 53 | const vatsimEmbed = new EmbedBuilder() 54 | .setTitle(`VATSIM: ${inlineCode(callSign)}`) 55 | .setColor("#0099ff") 56 | .setFooter({ 57 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums • Source: VATSIM API` 58 | }) 59 | .setTimestamp(); 60 | 61 | try { 62 | let vatsimClient = (await this._vatsimManager.getClientInfo(callSign, type)) as VatsimPilot | VatsimAtc; 63 | vatsimEmbed.addFields( 64 | { 65 | name: "Call Sign", 66 | value: vatsimClient.callsign, 67 | inline: true 68 | }, 69 | { 70 | name: "CID", 71 | value: vatsimClient.cid.toString(), 72 | inline: true 73 | }, 74 | { 75 | name: "Name", 76 | value: vatsimClient.name, 77 | inline: true 78 | } 79 | ); 80 | switch (type) { 81 | case "pilot": 82 | vatsimClient = vatsimClient as VatsimPilot; 83 | const departureAirport = vatsimClient.flight_plan ? await this._airportManager.getAirport(vatsimClient.flight_plan?.departure) : { name: "NA" }; 84 | const arrivalAirport = vatsimClient.flight_plan ? await this._airportManager.getAirport(vatsimClient.flight_plan?.arrival) : { name: "NA" }; 85 | vatsimEmbed.addFields( 86 | { 87 | name: "Departure", 88 | value: departureAirport.name, 89 | inline: true 90 | }, 91 | { 92 | name: "Destination", 93 | value: arrivalAirport.name, 94 | inline: true 95 | }, 96 | { 97 | name: "Transponder", 98 | value: vatsimClient.transponder, 99 | inline: true 100 | }, 101 | { 102 | name: "Latitude", 103 | value: vatsimClient.latitude.toString(), 104 | inline: true 105 | }, 106 | { 107 | name: "Longitude", 108 | value: vatsimClient.longitude.toString(), 109 | inline: true 110 | }, 111 | { 112 | name: "Altitude", 113 | value: `${vatsimClient.altitude} ft`, 114 | inline: true 115 | }, 116 | { 117 | name: "Ground Speed", 118 | value: `${vatsimClient.groundspeed} knots`, 119 | inline: true 120 | }, 121 | { 122 | name: "Cruising Speed", 123 | value: vatsimClient.flight_plan?.cruise_tas ?? "NA", 124 | inline: true 125 | }, 126 | { 127 | name: "Cruising Level", 128 | value: vatsimClient.flight_plan?.alternate ?? "NA", 129 | inline: true 130 | }, 131 | { 132 | name: "Departure Time", 133 | value: vatsimClient.flight_plan ? vatsimClient.flight_plan?.deptime?.toString().padStart(4, "0") + "Z" : "NA", 134 | inline: true 135 | }, 136 | { 137 | name: "EET", 138 | value: vatsimClient.flight_plan?.enroute_time?.toString().padStart(4, "0") ?? "NA", 139 | inline: true 140 | }, 141 | { 142 | name: "Aircraft", 143 | value: vatsimClient.flight_plan?.aircraft_faa ?? "NA", 144 | inline: true 145 | }, 146 | { 147 | name: "Route", 148 | value: codeBlock(vatsimClient.flight_plan?.route ?? "NA"), 149 | inline: false 150 | }, 151 | { 152 | name: "Remakes", 153 | value: codeBlock(vatsimClient.flight_plan?.remarks ?? "NA"), 154 | inline: false 155 | } 156 | ); 157 | break; 158 | case "atc": 159 | vatsimClient = vatsimClient as VatsimAtc; 160 | const fullInfo = await this._vatsimManager.getInfo(); 161 | vatsimEmbed.addFields( 162 | { 163 | name: "Position", 164 | value: fullInfo.facilities[vatsimClient.facility].long, 165 | inline: true 166 | }, 167 | { 168 | name: "Frequency", 169 | value: vatsimClient.frequency, 170 | inline: true 171 | }, 172 | { 173 | name: "ATIS", 174 | value: codeBlock(vatsimClient.text_atis?.join("\n") || "NA"), 175 | inline: false 176 | } 177 | ); 178 | break; 179 | } 180 | } catch (error) { 181 | logger.error(`[${client.shard.ids}] ${error}`); 182 | vatsimEmbed.setColor("#ff0000").setDescription(`${interaction.member}, ${error.message}`); 183 | } 184 | 185 | return InteractionUtils.replyOrFollowUp(interaction, { 186 | embeds: [vatsimEmbed] 187 | }); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/commands/Ivao.ts: -------------------------------------------------------------------------------- 1 | import { Category, NotBot } from "@discordx/utilities"; 2 | import { ApplicationCommandOptionType, AutocompleteInteraction, codeBlock, CommandInteraction, EmbedBuilder, inlineCode } from "discord.js"; 3 | import { Client, Discord, Guard, Slash, SlashChoice, SlashOption } from "discordx"; 4 | import { injectable } from "tsyringe"; 5 | 6 | import { GuildOnly } from "../guards/GuildOnly.js"; 7 | import { RequiredBotPerms } from "../guards/RequiredBotPerms.js"; 8 | import { AirportManager } from "../model/framework/manager/AirportManager.js"; 9 | import { IvaoManager } from "../model/framework/manager/IvaoManager.js"; 10 | import type { IvaoAtc, IvaoPilot } from "../model/Typeings.js"; 11 | import { IvaoAtcRatingEnum, IvaoPilotRatingEnum } from "../model/Typeings.js"; 12 | import logger from "../utils/LoggerFactory.js"; 13 | import { InteractionUtils, ObjectUtil } from "../utils/Utils.js"; 14 | 15 | @Discord() 16 | @Category("Flight Sim Network") 17 | @injectable() 18 | export class Ivao { 19 | public constructor(private _ivaoManager: IvaoManager, private _airportManager: AirportManager) {} 20 | 21 | @Slash({ 22 | description: "Gives you the information for the chosen call sign on the IVAO network" 23 | }) 24 | @Guard( 25 | NotBot, 26 | RequiredBotPerms({ 27 | textChannel: ["EmbedLinks"] 28 | }), 29 | GuildOnly 30 | ) 31 | public async ivao( 32 | @SlashChoice("atc", "pilot") 33 | @SlashOption({ 34 | name: "type", 35 | description: "What type of client would you like the bot to give information for?", 36 | type: ApplicationCommandOptionType.String, 37 | required: true 38 | }) 39 | type: "atc" | "pilot", 40 | @SlashOption({ 41 | name: "call-sign", 42 | description: "What call sign would you like the bot to give information for?", 43 | autocomplete: (interaction: AutocompleteInteraction) => InteractionUtils.search(interaction, IvaoManager), 44 | type: ApplicationCommandOptionType.String, 45 | required: true 46 | }) 47 | callSign: string, 48 | interaction: CommandInteraction, 49 | client: Client 50 | ): Promise { 51 | await interaction.deferReply(); 52 | callSign = callSign.toUpperCase(); 53 | 54 | const ivaoEmbed = new EmbedBuilder() 55 | .setTitle(`IVAO: ${inlineCode(callSign)}`) 56 | .setColor("#0099ff") 57 | .setFooter({ 58 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums • Source: IVAO API` 59 | }) 60 | .setTimestamp(); 61 | 62 | try { 63 | let ivaoClient = (await this._ivaoManager.getClientInfo(callSign, type)) as IvaoPilot | IvaoAtc; 64 | ivaoEmbed.setTitle(`IVAO: ${inlineCode(callSign)} (open on Webeye)`); 65 | ivaoEmbed.addFields( 66 | { 67 | name: "Call Sign", 68 | value: ivaoClient.callsign, 69 | inline: true 70 | }, 71 | { 72 | name: "VID", 73 | value: ivaoClient.userId.toString(), 74 | inline: true 75 | } 76 | ); 77 | switch (type) { 78 | case "pilot": 79 | ivaoClient = ivaoClient as IvaoPilot; 80 | ivaoEmbed.setURL(`https://webeye.ivao.aero/?pilotId=${ivaoClient.id}`); 81 | const departureAirport = await this._airportManager.getAirport(ivaoClient.flightPlan.departureId); 82 | const arrivalAirport = await this._airportManager.getAirport(ivaoClient.flightPlan.arrivalId); 83 | ivaoEmbed.addFields( 84 | { 85 | name: "Rating", 86 | value: ivaoClient.rating ? IvaoPilotRatingEnum[ivaoClient.rating.toString()] : "Unknown", 87 | inline: true 88 | }, 89 | { 90 | name: "Departure", 91 | value: departureAirport.name, 92 | inline: true 93 | }, 94 | { 95 | name: "Destination", 96 | value: arrivalAirport.name, 97 | inline: true 98 | }, 99 | { 100 | name: "Transponder", 101 | value: ivaoClient.lastTrack.transponder.toString().padStart(4, "0"), 102 | inline: true 103 | }, 104 | { 105 | name: "Latitude", 106 | value: ivaoClient.lastTrack.latitude.toString(), 107 | inline: true 108 | }, 109 | { 110 | name: "Longitude", 111 | value: ivaoClient.lastTrack.longitude.toString(), 112 | inline: true 113 | }, 114 | { 115 | name: "Altitude", 116 | value: `${ivaoClient.lastTrack.altitude.toString()} ft`, 117 | inline: true 118 | }, 119 | { 120 | name: "Ground Speed", 121 | value: `${ivaoClient.lastTrack.groundSpeed.toString()} knots`, 122 | inline: true 123 | }, 124 | { 125 | name: "Cruising Speed", 126 | value: ivaoClient.flightPlan.speed.toString(), 127 | inline: true 128 | }, 129 | { 130 | name: "Cruising Level", 131 | value: ivaoClient.flightPlan.level, 132 | inline: true 133 | }, 134 | { 135 | name: "Departure Time", 136 | value: this.parseTime(ivaoClient.flightPlan.departureTime) + "Z", 137 | inline: true 138 | }, 139 | { 140 | name: "EET", 141 | value: this.parseTime(ivaoClient.flightPlan.eet), 142 | inline: true 143 | }, 144 | { 145 | name: "Aircraft", 146 | value: ivaoClient.flightPlan.aircraftId, 147 | inline: true 148 | }, 149 | { 150 | name: "Route", 151 | value: codeBlock(ivaoClient.flightPlan.route), 152 | inline: false 153 | }, 154 | { 155 | name: "Remarks", 156 | value: codeBlock(ivaoClient.flightPlan.remarks), 157 | inline: false 158 | } 159 | ); 160 | break; 161 | case "atc": 162 | ivaoClient = ivaoClient as IvaoAtc; 163 | ivaoEmbed.setURL(`https://webeye.ivao.aero/?atcId=${ivaoClient.id}`); 164 | ivaoEmbed.addFields( 165 | { 166 | name: "Rating", 167 | value: ivaoClient.rating ? IvaoAtcRatingEnum[ivaoClient.rating.toString()] : "Unknown", 168 | inline: true 169 | }, 170 | { 171 | name: "Position", 172 | value: ivaoClient.atcSession.position, 173 | inline: true 174 | }, 175 | { 176 | name: "Frequency", 177 | value: ivaoClient.atcSession.frequency.toFixed(3).toString(), 178 | inline: true 179 | }, 180 | { 181 | name: "ATIS Revision", 182 | value: ivaoClient.atis.revision, 183 | inline: true 184 | }, 185 | { 186 | name: "ATIS", 187 | value: codeBlock(ivaoClient.atis.lines.map((line) => line.trim()).join("\n")), 188 | inline: false 189 | } 190 | ); 191 | break; 192 | } 193 | } catch (e) { 194 | logger.error(`[${client.shard.ids}] ${e}`); 195 | ivaoEmbed.setColor("#ff0000").setDescription(`${interaction.member}, ${e.message}`); 196 | } 197 | return InteractionUtils.replyOrFollowUp(interaction, { 198 | embeds: [ivaoEmbed] 199 | }); 200 | } 201 | 202 | private parseTime(time: number): string { 203 | return ObjectUtil.dayJsAsUtc.utc().startOf("day").add(time, "seconds").format("HHmm"); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/commands/Atis.ts: -------------------------------------------------------------------------------- 1 | import { AudioPlayer, AudioPlayerStatus, createAudioPlayer, createAudioResource, DiscordGatewayAdapterCreator, joinVoiceChannel, VoiceConnectionStatus } from "@discordjs/voice"; 2 | import { Category, NotBot } from "@discordx/utilities"; 3 | import type { AutocompleteInteraction, CommandInteraction, MessageActionRowComponentBuilder } from "discord.js"; 4 | import { ActionRowBuilder, ApplicationCommandOptionType, ButtonBuilder, ButtonInteraction, ButtonStyle, codeBlock, EmbedBuilder, GuildMember, inlineCode, VoiceBasedChannel } from "discord.js"; 5 | import { Client, Discord, Guard, Slash, SlashGroup, SlashOption } from "discordx"; 6 | import Text2Speech from "node-gtts"; 7 | import tmp from "tmp"; 8 | import { injectable } from "tsyringe"; 9 | 10 | import { GuildOnly } from "../guards/GuildOnly.js"; 11 | import { RequiredBotPerms } from "../guards/RequiredBotPerms.js"; 12 | import { AirportManager } from "../model/framework/manager/AirportManager.js"; 13 | import { AvwxManager } from "../model/framework/manager/AvwxManager.js"; 14 | import logger from "../utils/LoggerFactory.js"; 15 | import { InteractionUtils } from "../utils/Utils.js"; 16 | 17 | @Discord() 18 | @Category("Advisory") 19 | @SlashGroup({ name: "atis" }) 20 | @SlashGroup("atis") 21 | @injectable() 22 | export class Atis { 23 | private readonly _audioPlayers: Map = new Map(); 24 | // map of > 25 | private readonly _atisMap: Map>> = new Map(); 26 | 27 | public constructor(private _avwxManager: AvwxManager) {} 28 | 29 | @Slash({ 30 | name: "text", 31 | description: "Gives you the live ATIS as text for the chosen airport" 32 | }) 33 | @Guard( 34 | NotBot, 35 | RequiredBotPerms({ 36 | textChannel: ["EmbedLinks"] 37 | }) 38 | ) 39 | public async atisText( 40 | @SlashOption({ 41 | name: "icao", 42 | autocomplete: (interaction: AutocompleteInteraction) => InteractionUtils.search(interaction, AirportManager), 43 | description: "What ICAO would you like the bot to give ATIS for?", 44 | type: ApplicationCommandOptionType.String, 45 | required: true 46 | }) 47 | icao: string, 48 | interaction: CommandInteraction, 49 | client: Client 50 | ): Promise { 51 | await interaction.deferReply(); 52 | icao = icao.toUpperCase(); 53 | 54 | const atisEmbed = new EmbedBuilder() 55 | .setTitle(`ATIS: ${inlineCode(icao)}`) 56 | .setColor("#0099ff") 57 | .setFooter({ 58 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums • Source: AVWX` 59 | }) 60 | .setTimestamp(); 61 | try { 62 | const { speech } = await this._avwxManager.getMetar(icao); 63 | atisEmbed.setDescription(codeBlock(speech)); 64 | } catch (error) { 65 | logger.error(`[${client.shard.ids}] ${error}`); 66 | atisEmbed.setColor("#ff0000").setDescription(`${interaction.member}, ${error.message}`); 67 | } 68 | 69 | return InteractionUtils.replyOrFollowUp(interaction, { 70 | embeds: [atisEmbed] 71 | }); 72 | } 73 | 74 | @Slash({ 75 | name: "voice", 76 | description: "Gives you the live ATIS as voice for the chosen airport" 77 | }) 78 | @Guard( 79 | NotBot, 80 | RequiredBotPerms({ 81 | textChannel: ["EmbedLinks"], 82 | voice: ["Connect", "Speak"] 83 | }), 84 | GuildOnly 85 | ) 86 | public async atisVoice( 87 | @SlashOption({ 88 | name: "icao", 89 | autocomplete: (interaction: AutocompleteInteraction) => InteractionUtils.search(interaction, AirportManager), 90 | description: "What ICAO would you like the bot to give ATIS for?", 91 | type: ApplicationCommandOptionType.String, 92 | required: true 93 | }) 94 | icao: string, 95 | interaction: CommandInteraction, 96 | client: Client 97 | ): Promise { 98 | await interaction.deferReply(); 99 | icao = icao.toUpperCase(); 100 | 101 | const atisEmbed = new EmbedBuilder() 102 | .setTitle(`ATIS: ${inlineCode(icao)}`) 103 | .setColor("#0099ff") 104 | .setFooter({ 105 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums • Source: AVWX` 106 | }) 107 | .setTimestamp(); 108 | let atisFound = false; 109 | const voiceChannel = (interaction.member as GuildMember).voice.channel; 110 | try { 111 | await this.play(voiceChannel, interaction, client, atisEmbed, icao); 112 | atisFound = true; 113 | } catch (error) { 114 | logger.error(`[${client.shard.ids}] ${error}`); 115 | } 116 | if (!atisFound) { 117 | atisEmbed.setColor("#ff0000").setDescription(`${interaction.member}, no ATIS available at the moment for ${icao}`); 118 | return InteractionUtils.replyOrFollowUp(interaction, { 119 | embeds: [atisEmbed] 120 | }); 121 | } 122 | } 123 | 124 | private getAudioPlayer(guildId: string): AudioPlayer { 125 | if (this._audioPlayers.has(guildId)) { 126 | return this._audioPlayers.get(guildId); 127 | } 128 | const audioPlayer = createAudioPlayer(); 129 | this._audioPlayers.set(guildId, audioPlayer); 130 | return audioPlayer; 131 | } 132 | 133 | private async play(voiceChannel: VoiceBasedChannel, interaction: CommandInteraction, client: Client, embed: EmbedBuilder, icao: string): Promise { 134 | const { guildId } = voiceChannel; 135 | const file = await this.saveSpeechToFile(icao, embed); 136 | const resource = createAudioResource(file.name); 137 | const connection = joinVoiceChannel({ 138 | channelId: voiceChannel.id, 139 | guildId: voiceChannel.guild.id, 140 | adapterCreator: voiceChannel.guild.voiceAdapterCreator as unknown as DiscordGatewayAdapterCreator 141 | }); 142 | const audioPlayer = this.getAudioPlayer(guildId); 143 | connection.subscribe(audioPlayer); 144 | audioPlayer.play(resource); 145 | audioPlayer.on(AudioPlayerStatus.Idle, async () => { 146 | const isChannelEmpty = voiceChannel.members.filter((member) => member.id !== client.user.id).size === 0; 147 | if (isChannelEmpty) { 148 | if (connection.state.status !== VoiceConnectionStatus.Destroyed) { 149 | connection.destroy(); 150 | } 151 | } else { 152 | const newFIle = await this.saveSpeechToFile(icao, embed); 153 | audioPlayer.play(createAudioResource(newFIle.name)); 154 | } 155 | }); 156 | 157 | connection.on(VoiceConnectionStatus.Destroyed, async () => { 158 | await interaction.editReply({ 159 | components: [] 160 | }); 161 | this._audioPlayers.delete(guildId); 162 | }); 163 | 164 | const state = audioPlayer.state.status; 165 | const stopButton = new ButtonBuilder() 166 | .setLabel("Stop") 167 | .setStyle(ButtonStyle.Danger) 168 | .setDisabled(state === AudioPlayerStatus.Playing) 169 | .setCustomId("btn-stop"); 170 | const buttonRow = new ActionRowBuilder().addComponents(stopButton); 171 | 172 | const message = await interaction.followUp({ 173 | embeds: [embed], 174 | fetchReply: true, 175 | components: [buttonRow] 176 | }); 177 | 178 | const collector = message.createMessageComponentCollector(); 179 | 180 | collector.on("collect", async (collectInteraction: ButtonInteraction) => { 181 | const memberActivated = collectInteraction.member as GuildMember; 182 | // ensure the member who clicked this button is also in the voice channel 183 | if (memberActivated?.voice?.channelId !== voiceChannel.id) { 184 | return; 185 | } 186 | await collectInteraction.deferUpdate(); 187 | const buttonId = collectInteraction.customId; 188 | if (buttonId === "btn-stop") { 189 | connection.destroy(); 190 | } 191 | collector.stop(); 192 | }); 193 | } 194 | 195 | private async saveSpeechToFile(icao: string, embed: EmbedBuilder): Promise> { 196 | const { speech } = await this._avwxManager.getMetar(icao); 197 | embed.setDescription(codeBlock(speech)); 198 | if (this._atisMap.has(icao)) { 199 | const storedSpeech = this._atisMap.get(icao); 200 | if (storedSpeech.has(speech)) { 201 | return storedSpeech.get(speech); 202 | } 203 | } 204 | 205 | const tmpObj = tmp.fileSync({ 206 | postfix: ".mp3" 207 | }); 208 | return new Promise((resolve) => { 209 | const speechOb = Text2Speech("en-uk"); 210 | speechOb.save(tmpObj.name, speech, () => { 211 | const fileMap = new Map(); 212 | fileMap.set(speech, tmpObj); 213 | this._atisMap.set(icao, fileMap); 214 | resolve(tmpObj); 215 | }); 216 | }); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/commands/Poscon.ts: -------------------------------------------------------------------------------- 1 | import { Category, NotBot } from "@discordx/utilities"; 2 | import { ApplicationCommandOptionType, AutocompleteInteraction, codeBlock, CommandInteraction, EmbedBuilder, inlineCode } from "discord.js"; 3 | import { Client, Discord, Guard, Slash, SlashChoice, SlashOption } from "discordx"; 4 | import { injectable } from "tsyringe"; 5 | 6 | import { GuildOnly } from "../guards/GuildOnly.js"; 7 | import { RequiredBotPerms } from "../guards/RequiredBotPerms.js"; 8 | import { AirportManager } from "../model/framework/manager/AirportManager.js"; 9 | import { PosconManager } from "../model/framework/manager/PosconManager.js"; 10 | import type { PosconAtc, PosconFlight } from "../model/Typeings.js"; 11 | import logger from "../utils/LoggerFactory.js"; 12 | import { InteractionUtils, ObjectUtil } from "../utils/Utils.js"; 13 | 14 | @Discord() 15 | @Category("Flight Sim Network") 16 | @injectable() 17 | export class Poscon { 18 | public constructor(private _posconManager: PosconManager, private _airportManager: AirportManager) {} 19 | 20 | @Slash({ 21 | description: "Gives you the information for the chosen call sign on the POSCON network" 22 | }) 23 | @Guard( 24 | NotBot, 25 | RequiredBotPerms({ 26 | textChannel: ["EmbedLinks"] 27 | }), 28 | GuildOnly 29 | ) 30 | public async poscon( 31 | @SlashChoice("atc", "pilot") 32 | @SlashOption({ 33 | name: "type", 34 | description: "What type of client would you like the bot to give information for?", 35 | type: ApplicationCommandOptionType.String, 36 | required: true 37 | }) 38 | type: "atc" | "pilot", 39 | @SlashOption({ 40 | name: "ident", 41 | description: "What call sign or sector ID would you like the bot to give information for?", 42 | autocomplete: (interaction: AutocompleteInteraction) => InteractionUtils.search(interaction, PosconManager), 43 | type: ApplicationCommandOptionType.String, 44 | required: true 45 | }) 46 | callSign: string, 47 | interaction: CommandInteraction, 48 | client: Client 49 | ): Promise { 50 | await interaction.deferReply(); 51 | callSign = callSign.toUpperCase(); 52 | 53 | const posconEmbed = new EmbedBuilder() 54 | .setTitle(`POSCON: ${inlineCode(callSign)}`) 55 | .setColor("#0099ff") 56 | .setFooter({ 57 | text: `${client.user.username} • This is not a source for official briefing • Please use the appropriate forums • Source: POSCON API` 58 | }) 59 | .setTimestamp(); 60 | 61 | try { 62 | let posconClient = (await this._posconManager.getClientInfo(callSign, type)) as PosconFlight | PosconAtc; 63 | posconEmbed.setTitle(`POSCON: ${inlineCode(callSign)}`); 64 | 65 | switch (type) { 66 | case "pilot": 67 | posconClient = posconClient as PosconFlight; 68 | const departureAirport = posconClient.flightplan?.dep ? (await this._airportManager.getAirport(posconClient.flightplan.dep)).name : "N/A"; 69 | const arrivalAirport = posconClient.flightplan?.dest ? (await this._airportManager.getAirport(posconClient.flightplan.dest)).name : "N/A"; 70 | posconEmbed.addFields( 71 | { 72 | name: "Call Sign", 73 | value: posconClient.callsign, 74 | inline: true 75 | }, 76 | { 77 | name: "ID", 78 | value: posconClient.userId.toString(), 79 | inline: true 80 | }, 81 | { 82 | name: "Name", 83 | value: posconClient.userName.toString(), 84 | inline: true 85 | }, 86 | { 87 | name: "Departure", 88 | value: departureAirport, 89 | inline: true 90 | }, 91 | { 92 | name: "Destination", 93 | value: arrivalAirport, 94 | inline: true 95 | }, 96 | { 97 | name: "Transponder", 98 | value: posconClient.squawk.toString().padStart(4, "0"), 99 | inline: true 100 | }, 101 | { 102 | name: "Latitude", 103 | value: posconClient.position?.lat?.toString() ?? "N/A", 104 | inline: true 105 | }, 106 | { 107 | name: "Longitude", 108 | value: posconClient.position?.long?.toString() ?? "N/A", 109 | inline: true 110 | }, 111 | { 112 | name: "Altitude", 113 | value: `${posconClient.position?.alt_amsl?.toString() ?? "N/A"} ft`, 114 | inline: true 115 | }, 116 | { 117 | name: "Ground Speed", 118 | value: `${posconClient.position?.gs_kt?.toString() ?? 0} knots`, 119 | inline: true 120 | }, 121 | { 122 | name: "Cruising Speed", 123 | value: posconClient.flightplan?.cruise_spd.toString() ?? "N/A", 124 | inline: true 125 | }, 126 | { 127 | name: "Cruising Level", 128 | value: posconClient.flightplan?.cruise ?? "N/A", 129 | inline: true 130 | }, 131 | { 132 | name: "Departure Time", 133 | value: posconClient.flightplan ? posconClient.flightplan.dep_time + "Z" : "N/A", 134 | inline: true 135 | }, 136 | { 137 | name: "EET", 138 | value: posconClient.flightplan?.eet ?? "N/A", 139 | inline: true 140 | }, 141 | { 142 | name: "Aircraft", 143 | value: posconClient.flightplan?.ac_type ?? posconClient.ac_type ?? "N/A", 144 | inline: true 145 | }, 146 | { 147 | name: "VHF 1", 148 | value: (posconClient.freq.vhf1 / 1000).toFixed(3).toString(), 149 | inline: true 150 | }, 151 | { 152 | name: "VHF 2", 153 | value: (posconClient.freq.vhf2 / 1000).toFixed(3).toString(), 154 | inline: true 155 | }, 156 | { 157 | name: "Airline/Operator", 158 | value: posconClient.flightplan?.operator ?? posconClient.airline ?? "N/A", 159 | inline: true 160 | }, 161 | { 162 | name: "Route", 163 | value: codeBlock(posconClient.flightplan?.route ?? "N/A"), 164 | inline: false 165 | }, 166 | { 167 | name: "Other", 168 | value: codeBlock(posconClient.flightplan?.other ?? "N/A"), 169 | inline: false 170 | } 171 | ); 172 | break; 173 | case "atc": 174 | posconClient = posconClient as PosconAtc; 175 | posconEmbed.addFields( 176 | { 177 | name: "Sector ID", 178 | value: posconClient.position, 179 | inline: true 180 | }, 181 | { 182 | name: "ID", 183 | value: posconClient.userId.toString(), 184 | inline: true 185 | }, 186 | { 187 | name: "Name", 188 | value: posconClient.userName.toString(), 189 | inline: true 190 | }, 191 | { 192 | name: "Position", 193 | value: posconClient.type, 194 | inline: true 195 | }, 196 | { 197 | name: "Telephony", 198 | value: posconClient.telephony, 199 | inline: true 200 | }, 201 | { 202 | name: "Frequency", 203 | value: posconClient.vhfFreq, 204 | inline: true 205 | } 206 | ); 207 | break; 208 | } 209 | } catch (e) { 210 | logger.error(`[${client.shard.ids}] ${e}`, e); 211 | posconEmbed.setColor("#ff0000").setDescription(`${interaction.member}, ${e.message}`); 212 | } 213 | return InteractionUtils.replyOrFollowUp(interaction, { 214 | embeds: [posconEmbed] 215 | }); 216 | } 217 | 218 | private parseTime(time: number): string { 219 | return ObjectUtil.dayJsAsUtc.utc().startOf("day").add(time, "seconds").format("HHmm"); 220 | } 221 | } 222 | --------------------------------------------------------------------------------