diff --git a/bun.lockb b/bun.lockb index 840c710..a98a554 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 7c40542..f1aac22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "legica-dana", - "version": "0.8.0", + "version": "2.0.0", "main": "src/app.ts", "scripts": { "start": "bun src/app.ts" @@ -8,25 +8,24 @@ "author": "Fran Jurmanović ", "license": "MIT", "dependencies": { - "@types/node": "^14.14.31", + "@elysiajs/static": "^0.7.1", + "@elysiajs/swagger": "^0.7.3", "axios": "^0.26.0", "body-parser": "^1.20.2", "cheerio": "^1.0.0-rc.10", "cron": "^3.0.0", "discord.js": "^12.5.1", "dotenv": "^8.2.0", - "express": "^4.18.2", - "express-basic-auth": "^1.2.1", - "redoc-express": "^2.1.0", + "elysia": "^0.7.15", + "minimatch": "^9.0.3", + "pino": "^8.15.4", "typescript": "^4.1.5" }, "devDependencies": { - "@types/express": "^4.17.18", - "@types/node-cron": "^3.0.1", - "@types/pg": "^7.14.10", - "@types/ws": "^7.4.0", + "@types/node": "^20.8.2", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", + "bun-types": "^1.0.4-canary.20231004T140131", "eslint": "^8.50.0", "prettier": "^2.2.1" } diff --git a/src/app.ts b/src/app.ts index 59befa7..ef9948b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,60 +1,126 @@ import { Client } from "discord.js"; -import { Chat } from "@common"; -import { Controller } from "@core"; -import { ClientController } from "@controllers"; -import express from "express"; -import { APP_VERSION, config } from "@constants"; -import bodyParser from "body-parser"; -import redoc from "redoc-express"; -import path from "path"; +import { config } from "@constants"; +import { CronJob } from "cron"; +import { sendDiscordMessage, sendNextMessage } from "@common"; +import { Elysia, t } from "elysia"; +import { swagger } from "@elysiajs/swagger"; +import { basicAuth } from "@core"; +import pino from "pino"; +import staticPlugin from "@elysiajs/static"; 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) => { - res.sendFile("swagger.json", { root: path.join(__dirname, "..") }); + options: { destination: `app.log` }, }); -app.get( - "/docs", - redoc({ - title: "API Docs", - specUrl: "/docs/swagger.json", - nonce: "", - redocOptions: { - theme: { - colors: { - primary: { - main: "#6EC5AB", - }, +const logger = pino( + { + level: "error", + }, + 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`, - fontSize: "15px", - lineHeight: "1.5", - code: { - code: "#87E8C7", - backgroundColor: "#4D4D4E", - }, - }, - menu: { - backgroundColor: "#ffffff", + ], + errorMessage: "Unauthorized", + }) + ) + .get("/", ({ store: { job } }) => ({ + running: job?.running ?? false, + next: job?.nextDate().toISO(), + })) + .post("/", ({ store: { job }, set }) => { + if (job?.running) { + 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, }, }, - }, - }) -); - -app.get("/version", (_, res) => { - res.send(APP_VERSION); -}); - -const controllers = new Controller(app, [new ClientController(client)]); - -controllers.register(); -chat.register(config.TOKEN || ""); -app.listen(config.PORT, () => - console.log(`Legica bot API listening on port ${config.PORT}!`) + }) + ) + .use(staticPlugin()) + .use(taskPlugin) + .listen(config.PORT); + +console.log( + `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` ); diff --git a/src/common/chat.ts b/src/common/chat.ts deleted file mode 100644 index 03190b8..0000000 --- a/src/common/chat.ts +++ /dev/null @@ -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, - }, - ]; - }; -} diff --git a/src/common/getFirstHtml.ts b/src/common/getFirstHtml.ts new file mode 100644 index 0000000..764b150 --- /dev/null +++ b/src/common/getFirstHtml.ts @@ -0,0 +1,10 @@ +import axios from "axios"; +import cheerio from "cheerio"; + +export async function getFirstHtml(): Promise { + 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; +} diff --git a/src/common/getImgTitle.ts b/src/common/getImgTitle.ts new file mode 100644 index 0000000..8166e2f --- /dev/null +++ b/src/common/getImgTitle.ts @@ -0,0 +1,14 @@ +import { Legica } from "@models"; +import axios from "axios"; +import cheerio from "cheerio"; + +export async function getImgTitle(href: string): Promise { + 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 }; +} diff --git a/src/common/index.ts b/src/common/index.ts index 24fe1c6..1139ec3 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -1 +1,3 @@ -export { default as Chat } from "./chat"; +export { getFirstHtml } from "./getFirstHtml"; +export { getImgTitle } from "./getImgTitle"; +export { sendDiscordMessage, sendNextMessage } from "./sendDiscordMessage"; diff --git a/src/common/sendDiscordMessage.ts b/src/common/sendDiscordMessage.ts new file mode 100644 index 0000000..64ab73a --- /dev/null +++ b/src/common/sendDiscordMessage.ts @@ -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 { + 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 { + try { + const href = await getFirstHtml(); + await sendDiscordMessage(client, href); + } catch (err) { + console.error(err); + } +} diff --git a/src/constants/config.ts b/src/constants/config.ts index c70e8b5..93130aa 100644 --- a/src/constants/config.ts +++ b/src/constants/config.ts @@ -1,11 +1,21 @@ import { config as dotenv } from "dotenv"; +import { version } from "../../package.json"; 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, PASSWORD: process.env.PASSWORD, PORT: process.env.PORT || "3000", CRON_LEGICA: process.env.CRON_LEGICA || "0 9 * * *", + APP_VERSION: version, + LEGICA_URL: "https://sib.net.hr/legica-dana", }; export { config }; diff --git a/src/constants/index.ts b/src/constants/index.ts index b56936a..5c62e04 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,2 +1 @@ -export * from "./version"; export * from "./config"; diff --git a/src/constants/version.ts b/src/constants/version.ts deleted file mode 100644 index a0cbd1f..0000000 --- a/src/constants/version.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { version } from "../../package.json"; - -export const APP_VERSION = version; diff --git a/src/controllers/Client.controller.ts b/src/controllers/Client.controller.ts deleted file mode 100644 index 7b9e6c3..0000000 --- a/src/controllers/Client.controller.ts +++ /dev/null @@ -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 => { - try { - const href = await getFirstHtml(); - await this.sendMessage(href); - } catch (err) { - console.error(err); - } - }; - - private sendMessage = async (url: string): Promise => { - 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 { - 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 { - 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; diff --git a/src/controllers/index.ts b/src/controllers/index.ts deleted file mode 100644 index 1aa324d..0000000 --- a/src/controllers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as ClientController } from "./Client.controller"; diff --git a/src/core/Controller.ts b/src/core/Controller.ts deleted file mode 100644 index b3a7089..0000000 --- a/src/core/Controller.ts +++ /dev/null @@ -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; diff --git a/src/core/basicAuth.ts b/src/core/basicAuth.ts new file mode 100644 index 0000000..3e34a63 --- /dev/null +++ b/src/core/basicAuth.ts @@ -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; +}; diff --git a/src/core/index.ts b/src/core/index.ts index 97e1555..06d786c 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1 +1 @@ -export { default as Controller } from "./Controller"; +export { basicAuth } from "./basicAuth"; diff --git a/src/models/Controller.ts b/src/models/Controller.ts deleted file mode 100644 index 3816a37..0000000 --- a/src/models/Controller.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Router } from "express"; - -export interface IController { - register(): void; - registerRouter(): Router; - path: string; -} diff --git a/src/models/index.ts b/src/models/index.ts index 6ee8930..78755f2 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,3 +1,2 @@ -export * from "./Controller"; export * from "./Command"; export * from "./Legica"; diff --git a/src/modules/Common.module.ts b/src/modules/Common.module.ts deleted file mode 100644 index 9e7b1e2..0000000 --- a/src/modules/Common.module.ts +++ /dev/null @@ -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; diff --git a/src/modules/index.ts b/src/modules/index.ts deleted file mode 100644 index 74083ce..0000000 --- a/src/modules/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as CommonModule } from "./Common.module"; diff --git a/swagger.json b/swagger.json deleted file mode 100644 index 9514b2e..0000000 --- a/swagger.json +++ /dev/null @@ -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" - } - } - } - } - } \ No newline at end of file