use elysia instead of express

This commit is contained in:
Fran Jurmanović
2023-10-04 20:19:38 +02:00
parent aea2acbdf8
commit 82acbf93fc
21 changed files with 276 additions and 455 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,6 +1,6 @@
{ {
"name": "legica-dana", "name": "legica-dana",
"version": "0.8.0", "version": "2.0.0",
"main": "src/app.ts", "main": "src/app.ts",
"scripts": { "scripts": {
"start": "bun src/app.ts" "start": "bun src/app.ts"
@@ -8,25 +8,24 @@
"author": "Fran Jurmanović <fjurma12@outlook.com>", "author": "Fran Jurmanović <fjurma12@outlook.com>",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "^14.14.31", "@elysiajs/static": "^0.7.1",
"@elysiajs/swagger": "^0.7.3",
"axios": "^0.26.0", "axios": "^0.26.0",
"body-parser": "^1.20.2", "body-parser": "^1.20.2",
"cheerio": "^1.0.0-rc.10", "cheerio": "^1.0.0-rc.10",
"cron": "^3.0.0", "cron": "^3.0.0",
"discord.js": "^12.5.1", "discord.js": "^12.5.1",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"express": "^4.18.2", "elysia": "^0.7.15",
"express-basic-auth": "^1.2.1", "minimatch": "^9.0.3",
"redoc-express": "^2.1.0", "pino": "^8.15.4",
"typescript": "^4.1.5" "typescript": "^4.1.5"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.18", "@types/node": "^20.8.2",
"@types/node-cron": "^3.0.1",
"@types/pg": "^7.14.10",
"@types/ws": "^7.4.0",
"@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4", "@typescript-eslint/parser": "^6.7.4",
"bun-types": "^1.0.4-canary.20231004T140131",
"eslint": "^8.50.0", "eslint": "^8.50.0",
"prettier": "^2.2.1" "prettier": "^2.2.1"
} }

View File

@@ -1,60 +1,126 @@
import { Client } from "discord.js"; import { Client } from "discord.js";
import { Chat } from "@common"; import { config } from "@constants";
import { Controller } from "@core"; import { CronJob } from "cron";
import { ClientController } from "@controllers"; import { sendDiscordMessage, sendNextMessage } from "@common";
import express from "express"; import { Elysia, t } from "elysia";
import { APP_VERSION, config } from "@constants"; import { swagger } from "@elysiajs/swagger";
import bodyParser from "body-parser"; import { basicAuth } from "@core";
import redoc from "redoc-express"; import pino from "pino";
import path from "path"; import staticPlugin from "@elysiajs/static";
const client: Client = new Client(); const client: Client = new Client();
const chat: Chat = new Chat(client);
const app = express();
app.use(bodyParser.json()); const fileTransport = pino.transport({
target: "pino/file",
app.get("/docs/swagger.json", (req, res) => { options: { destination: `app.log` },
res.sendFile("swagger.json", { root: path.join(__dirname, "..") });
}); });
app.get( const logger = pino(
"/docs", {
redoc({ level: "error",
title: "API Docs",
specUrl: "/docs/swagger.json",
nonce: "",
redocOptions: {
theme: {
colors: {
primary: {
main: "#6EC5AB",
}, },
fileTransport
);
const taskPlugin = new Elysia({ prefix: "/job" })
.state("job", null as CronJob | null)
.onStart(({ store }) => {
client.on("ready", (): void => {
if (store.job) {
store.job.stop();
}
store.job = new CronJob(
config.CRON_LEGICA,
() => sendNextMessage(client),
null,
true,
"utc"
);
});
})
.onBeforeHandle(({ store: { job }, set }) => {
if (!job) {
set.status = 400;
return "Job is not running.";
}
})
.use(
basicAuth({
users: [
{
username: "admin",
password: config.PASSWORD,
}, },
typography: { ],
fontFamily: `"museo-sans", 'Helvetica Neue', Helvetica, Arial, sans-serif`, errorMessage: "Unauthorized",
fontSize: "15px", })
lineHeight: "1.5", )
code: { .get("/", ({ store: { job } }) => ({
code: "#87E8C7", running: job?.running ?? false,
backgroundColor: "#4D4D4E", next: job?.nextDate().toISO(),
}, }))
}, .post("/", ({ store: { job }, set }) => {
menu: { if (job?.running) {
backgroundColor: "#ffffff", set.status = 400;
return "Task already running";
}
job?.start();
return "Task started";
})
.delete("/", ({ store: { job }, set }) => {
if (!job?.running) {
set.status = 400;
return "Task already stopped";
}
job?.stop();
return "Task stopped";
})
.post(
"/send",
async ({ set, body }) => {
try {
const url = body.url;
if (url) {
await sendDiscordMessage(client, url);
} else {
await sendNextMessage(client);
}
return true;
} catch (err) {
set.status = 400;
return err;
}
}, },
{
body: t.Object({
url: t.String(),
}),
}
)
.get("/log", () => Bun.file("app.log"));
client.login(config.TOKEN);
const app = new Elysia()
.onError(({ error }) => {
logger.error(error);
return new Response(error.toString());
})
.get("/", () => config.APP_VERSION)
.use(
swagger({
documentation: {
info: {
title: "Legica Bot",
version: config.APP_VERSION,
}, },
}, },
}) })
); )
.use(staticPlugin())
app.get("/version", (_, res) => { .use(taskPlugin)
res.send(APP_VERSION); .listen(config.PORT);
});
console.log(
const controllers = new Controller(app, [new ClientController(client)]); `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
controllers.register();
chat.register(config.TOKEN || "");
app.listen(config.PORT, () =>
console.log(`Legica bot API listening on port ${config.PORT}!`)
); );

View File

@@ -1,48 +0,0 @@
import { CommandFunction, ICommand } from "@models";
import type { Client, Message } from "discord.js";
export default class Chat {
private prefix: string = "!";
constructor(private client: Client, private commands: ICommand[] = []) {}
public registerPrefix = (prefix: string): void => {
this.prefix = prefix;
};
public register = (token: string): void => {
if (!this.commands) return;
this.client.on("message", (message: Message): void => {
this.commands.forEach((command) => {
if (message?.content === `${this.prefix}${command?.name}`) {
command?.callback?.(message);
} else if (
message?.content?.split?.(/\s/g)?.[0] == `${this.prefix}${command?.name}`
) {
const args = message?.content
?.replace?.(`${this.prefix}${command?.name}`, "")
.trim?.()
?.split?.(/\s(?=(?:[^'"`]*(['"`])[^'"`]*\1)*[^'"`]*$)/g)
.map((d) => {
if (d?.[0] == '"' && d?.[d?.length - 1] == '"') {
return d?.substr?.(1)?.slice?.(0, -1);
}
return d;
})
.filter((d) => d);
command?.callback?.(message, args);
}
});
});
this.client.login(token);
};
public command = (name: string, callback: CommandFunction): void => {
this.commands = [
...this.commands,
{
name,
callback,
},
];
};
}

View File

@@ -0,0 +1,10 @@
import axios from "axios";
import cheerio from "cheerio";
export async function getFirstHtml(): Promise<string> {
const response = await axios.get("https://sib.net.hr/legica-dana");
const html = response.data;
const $ = cheerio.load(html);
const { href } = $(".News-link.c-def")?.attr() || {};
return href;
}

14
src/common/getImgTitle.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Legica } from "@models";
import axios from "axios";
import cheerio from "cheerio";
export async function getImgTitle(href: string): Promise<Legica> {
const response = await axios.get(href);
const html = response.data;
const $ = cheerio.load(html);
const title = $(".Article-inner > h1").text();
const { src: img } = $(".Article-media > img").attr() || {};
return { title, img };
}

View File

@@ -1 +1,3 @@
export { default as Chat } from "./chat"; export { getFirstHtml } from "./getFirstHtml";
export { getImgTitle } from "./getImgTitle";
export { sendDiscordMessage, sendNextMessage } from "./sendDiscordMessage";

View File

@@ -0,0 +1,48 @@
import { getFirstHtml, getImgTitle } from "@common";
import { Client, MessageEmbed, TextChannel } from "discord.js";
export async function sendDiscordMessage(
client: Client,
url: string
): Promise<void> {
if (!url) return;
const { img, title } = await getImgTitle(url);
client.channels.cache.forEach(async (channel) => {
try {
if (channel.type !== "text") return null;
const embeddedMessage = new MessageEmbed().setTitle(title).setImage(img);
const msg = await (channel as TextChannel).send(embeddedMessage);
const reactions = [
"1⃣",
"2⃣",
"3⃣",
"4⃣",
"5⃣",
"6⃣",
"7⃣",
"8⃣",
"9⃣",
"🔟",
];
for (const reaction of reactions) {
try {
await msg.react(reaction);
} catch {
console.error(`Reaction ${reaction} to channel ${channel.id} failed.`);
}
}
} catch {
console.error(`Message to channel ${channel.id} failed.`);
}
});
}
export async function sendNextMessage(client: Client): Promise<void> {
try {
const href = await getFirstHtml();
await sendDiscordMessage(client, href);
} catch (err) {
console.error(err);
}
}

View File

@@ -1,11 +1,21 @@
import { config as dotenv } from "dotenv"; import { config as dotenv } from "dotenv";
import { version } from "../../package.json";
dotenv(); dotenv();
const config: NodeJS.ProcessEnv = { type Config = {
APP_VERSION: string;
LEGICA_URL: string;
};
export type ProjectConfig = Config & NodeJS.ProcessEnv;
const config: ProjectConfig = {
TOKEN: process.env.TOKEN, TOKEN: process.env.TOKEN,
PASSWORD: process.env.PASSWORD, PASSWORD: process.env.PASSWORD,
PORT: process.env.PORT || "3000", PORT: process.env.PORT || "3000",
CRON_LEGICA: process.env.CRON_LEGICA || "0 9 * * *", CRON_LEGICA: process.env.CRON_LEGICA || "0 9 * * *",
APP_VERSION: version,
LEGICA_URL: "https://sib.net.hr/legica-dana",
}; };
export { config }; export { config };

View File

@@ -1,2 +1 @@
export * from "./version";
export * from "./config"; export * from "./config";

View File

@@ -1,3 +0,0 @@
import { version } from "../../package.json";
export const APP_VERSION = version;

View File

@@ -1,151 +0,0 @@
import { Client, MessageEmbed, TextChannel } from "discord.js";
import * as cron from "cron";
import axios from "axios";
import cheerio from "cheerio";
import { Router } from "express";
import { IController, Legica } from "@models";
import { config } from "@constants";
import basicAuth from "express-basic-auth";
class ClientController implements IController {
private legicaTask: cron.CronJob | null = null;
public path: string = "/task";
constructor(private client: Client) {}
public register = (): void => {
this.client.on("ready", (): void => {
this.legicaTask = new cron.CronJob(
config.CRON_LEGICA,
this.sendNextMessage,
null,
true,
"utc"
);
});
};
public registerRouter = (): Router => {
const router = Router();
router.use(
basicAuth({
users: {
admin: config.PASSWORD,
},
})
);
router.get("/", (_, res) => {
res.send(this.legicaTask?.running);
});
router.post("/", (_, res) => {
if (this.legicaTask?.running) {
res.status(400).send("Task already running.");
} else {
this.legicaTask?.start();
res.send("Task started.");
}
});
router.delete("/", (_, res) => {
if (!this.legicaTask?.running) {
res.status(400).send("Task already stopped.");
} else {
this.legicaTask.stop();
res.send("Task stopped.");
}
});
router.get("/next", (_, res) => {
if (!this.legicaTask?.running) {
res.status(400).send("Task is not running.");
} else {
res.send(this.legicaTask.nextDate().toISO());
}
});
router.post("/send-latest", async (_, res) => {
try {
await this.sendNextMessage();
res.send(true);
} catch (err) {
res.status(400).send(err);
}
});
router.post("/send", async (req, res) => {
try {
const url = req.body.url;
await this.sendMessage(url);
res.send(true);
} catch (err) {
res.status(400).send(err);
}
});
return router;
};
private sendNextMessage = async (): Promise<void> => {
try {
const href = await getFirstHtml();
await this.sendMessage(href);
} catch (err) {
console.error(err);
}
};
private sendMessage = async (url: string): Promise<void> => {
if (!url) return;
const { img, title } = await getImgTitle(url);
this.client.channels.cache.forEach(async (channel) => {
try {
if (channel.type !== "text") return null;
const embeddedMessage = new MessageEmbed().setTitle(title).setImage(img);
const msg = await (channel as TextChannel).send(embeddedMessage);
const reactions = [
"1⃣",
"2⃣",
"3⃣",
"4⃣",
"5⃣",
"6⃣",
"7⃣",
"8⃣",
"9⃣",
"🔟",
];
for (const reaction of reactions) {
try {
await msg.react(reaction);
} catch {
console.error(`Reaction ${reaction} to channel ${channel.id} failed.`);
}
}
} catch {
console.error(`Message to channel ${channel.id} failed.`);
}
});
};
}
async function getImgTitle(href: string): Promise<Legica> {
const response = await axios.get(href);
const html = response.data;
const $ = cheerio.load(html);
const title = $(".Article-inner > h1").text();
const { src: img } = $(".Article-media > img").attr() || {};
return { title, img };
}
async function getFirstHtml(): Promise<string> {
const response = await axios.get("https://sib.net.hr/legica-dana");
const html = response.data;
const $ = cheerio.load(html);
const { href } = $(".News-link.c-def")?.attr() || {};
return href;
}
export default ClientController;

View File

@@ -1 +0,0 @@
export { default as ClientController } from "./Client.controller";

View File

@@ -1,15 +0,0 @@
import { IController } from "models";
import { Express } from "express";
class Controller {
constructor(private app: Express, private controllers: IController[]) {}
public register = (): void => {
this.controllers?.forEach((controller) => {
controller.register();
this.app.use(controller.path || "", controller.registerRouter());
});
};
}
export default Controller;

65
src/core/basicAuth.ts Normal file
View File

@@ -0,0 +1,65 @@
import Elysia from "elysia";
import { minimatch } from "minimatch";
export class BasicAuthError extends Error {
constructor(public message: string) {
super(message);
}
}
export interface BasicAuthUser {
username: string;
password: string;
}
export interface BasicAuthConfig {
users: BasicAuthUser[];
realm?: string;
errorMessage?: string;
exclude?: string[];
noErrorThrown?: boolean;
}
export const basicAuth = (config: BasicAuthConfig) =>
new Elysia({ name: "basic-auth", seed: config })
.error({ BASIC_AUTH_ERROR: BasicAuthError })
.derive((ctx) => {
const authorization = ctx.headers?.authorization;
if (!authorization) return { basicAuth: { isAuthed: false, username: "" } };
const [username, password] = atob(authorization.split(" ")[1]).split(":");
const user = config.users.find(
(user) => user.username === username && user.password === password
);
if (!user) return { basicAuth: { isAuthed: false, username: "" } };
return { basicAuth: { isAuthed: true, username: user.username } };
})
.onTransform((ctx) => {
if (
!ctx.basicAuth.isAuthed &&
!config.noErrorThrown &&
!isPathExcluded(ctx.path, config.exclude) &&
ctx.request &&
ctx.request.method !== "OPTIONS"
)
throw new BasicAuthError(config.errorMessage ?? "Unauthorized");
})
.onError((ctx) => {
if (ctx.code === "BASIC_AUTH_ERROR") {
return new Response(ctx.error.message, {
status: 401,
headers: {
"WWW-Authenticate": `Basic${
config.realm ? ` realm="${config.realm}"` : ""
}`,
},
});
}
});
export const isPathExcluded = (path: string, excludedPatterns?: string[]) => {
if (!excludedPatterns) return false;
for (const pattern of excludedPatterns) {
if (minimatch(path, pattern)) return true;
}
return false;
};

View File

@@ -1 +1 @@
export { default as Controller } from "./Controller"; export { basicAuth } from "./basicAuth";

View File

@@ -1,7 +0,0 @@
import { Router } from "express";
export interface IController {
register(): void;
registerRouter(): Router;
path: string;
}

View File

@@ -1,3 +1,2 @@
export * from "./Controller";
export * from "./Command"; export * from "./Command";
export * from "./Legica"; export * from "./Legica";

View File

@@ -1,11 +0,0 @@
import { Message } from "discord.js";
import { APP_VERSION } from "../constants";
class CommonModule {
constructor() {}
public showVersion = (message: Message): void => {
message?.channel?.send?.(`Current version of the Monke BOT is ${APP_VERSION}.`);
};
}
export default CommonModule;

View File

@@ -1 +0,0 @@
export { default as CommonModule } from "./Common.module";

View File

@@ -1,154 +0,0 @@
{
"openapi": "3.0.3",
"info": {
"title": "Legica Bot API",
"license": {
"name": "Apache 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.html"
},
"version": "0.8.0"
},
"tags": [
{
"name": "api",
"description": "API information"
},
{
"name": "task",
"description": "Everything about the task"
}
],
"paths": {
"/version": {
"get": {
"tags": [
"api"
],
"summary": "Display current API version.",
"description": "Displays the current API version defined in package.json.",
"responses": {
"200": {
"description": "Successful operation"
}
}
}
},
"/task": {
"get": {
"tags": [
"task"
],
"summary": "Check if task is running.",
"description": "Retrieve the current state of scheduled task.",
"responses": {
"200": {
"description": "Successful operation"
}
}
},
"post": {
"tags": [
"task"
],
"summary": "Start task if it is not running.",
"description": "Starts the task if it is not currently running.",
"responses": {
"200": {
"description": "Task started."
},
"400": {
"description": "Task already running."
}
}
},
"delete": {
"tags": [
"task"
],
"summary": "Stop task if it is running.",
"description": "Stops the task if it is currently running.",
"responses": {
"200": {
"description": "Task stopped."
},
"400": {
"description": "Task already stopped."
}
}
}
},
"/task/next": {
"get": {
"tags": [
"task"
],
"summary": "Check when the task is scheduled due next.",
"description": "Retrieve the datetime when task is scheduled to execute.",
"responses": {
"200": {
"description": "Next datetime"
},
"400": {
"description": "Task is not running."
}
}
}
},
"/task/send-latest": {
"post": {
"tags": [
"task"
],
"summary": "Send latest post of legica dana.",
"description": "Sends latest post of legica dana to all discord channels.",
"responses": {
"200": {
"description": "Confirmation."
}
}
}
},
"/task/send": {
"post": {
"tags": [
"task"
],
"summary": "Send post of legica dana.",
"description": "Sends provided post of legica dana to all discord channels.",
"requestBody": {
"description": "URL",
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Legica"
}
}
}
},
"responses": {
"200": {
"description": "Confirmation."
}
}
}
}
},
"components": {
"schemas": {
"Legica": {
"type": "object",
"properties": {
"url": {
"type": "string",
"format": "string",
"example": "https://sib.net.hr/legica-dana/4390659/legica-dana-2992023/"
}
},
"xml": {
"name": "order"
}
}
}
}
}