18 Commits

Author SHA1 Message Date
Fran Jurmanović
3c6bdeab60 add build script 2024-05-20 23:18:22 +02:00
Fran Jurmanović
11a26bff83 version increase 2024-04-17 20:54:09 +02:00
Fran Jurmanović
e1cf854d27 remove static plugin 2024-04-17 20:53:36 +02:00
Fran Jurmanović
972ee67f53 remove static plugin 2024-04-17 20:49:05 +02:00
Fran Jurmanović
9ecfdef62b update dockerfile 2024-04-17 20:39:46 +02:00
Fran Jurmanović
e56f0883cf increase version 2024-03-02 14:50:29 +01:00
Fran Jurmanović
5aa69588fb add bot endpoints 2024-03-02 14:49:57 +01:00
Fran Jurmanović
28b028d056 increase version 2023-11-01 15:48:41 +01:00
Fran Jurmanović
4ac04d4457 formatting 2023-11-01 15:48:15 +01:00
Fran Jurmanović
9d137b6b52 fix legica date format 2023-11-01 15:46:04 +01:00
Fran Jurmanović
79110bdb1c version increase 2023-10-21 11:51:25 +02:00
Fran Jurmanović
0d2fba3883 fix regex not working everytime 2023-10-21 11:50:47 +02:00
Fran Jurmanović
77aa924161 increase version 2023-10-21 11:50:23 +02:00
Fran Jurmanović
b4b6f42a1a increment version 2023-10-14 01:09:22 +02:00
Fran Jurmanović
dd0b7cde36 add date check and fix send next post not working 2023-10-14 01:04:04 +02:00
Fran Jurmanović
162708812d fix error handling and more swagger info 2023-10-05 19:06:01 +02:00
Fran Jurmanović
71c9d1635b use constant url 2023-10-05 19:05:33 +02:00
Fran Jurmanović
07f9b5c581 update readme 2023-10-04 20:28:15 +02:00
11 changed files with 278 additions and 114 deletions

View File

@@ -1 +1,7 @@
# monke-bot # Legica Bot
Discord bot that scrapes the https://sib.net.hr/legica-dana website and posts the latest legica-dana post to all discord text channels it has permissions to.
## Documentation
[Documentation](https://legica.jurmanovic.com/swagger)

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,17 +1,20 @@
# Use oven/bun as parent image # Use oven/bun as parent image
FROM oven/bun:latest FROM oven/bun:1.0.27
# Change the working directory on the Docker image to /app # Change the working directory on the Docker image to /app
WORKDIR /app WORKDIR /app
# Copy package.json and package-lock.json to the /app directory # Copy package.json and package-lock.json to the /app directory
COPY . . COPY package.json ./
COPY bun.lockb ./
COPY src ./src
COPY tsconfig.json ./
# Install dependencies # Install dependencies
RUN bun install RUN bun install --frozen-lockfile
# Expose application port # Expose application port
EXPOSE 3000 EXPOSE 3000
# Start the application # Start the application
CMD bun start CMD ["bun", "start"]

View File

@@ -1,32 +1,38 @@
{ {
"name": "legica-dana", "name": "legica-dana",
"version": "2.0.0", "version": "2.0.7",
"main": "src/app.ts", "main": "src/app.ts",
"scripts": { "scripts": {
"start": "bun src/app.ts" "start": "bun run src/app.ts",
}, "build-script": "bun build src/app.ts --outdir dist --target bun",
"author": "Fran Jurmanović <fjurma12@outlook.com>", "start-build": "bun run dist/app.js"
"license": "MIT", },
"dependencies": { "author": "Fran Jurmanović <fjurma12@outlook.com>",
"@elysiajs/static": "^0.7.1", "license": "MIT",
"@elysiajs/swagger": "^0.7.3", "dependencies": {
"axios": "^0.26.0", "@elysiajs/cron": "^0.7.0",
"body-parser": "^1.20.2", "@elysiajs/static": "^0.7.1",
"cheerio": "^1.0.0-rc.10", "@elysiajs/swagger": "^0.7.3",
"cron": "^3.0.0", "axios": "^0.26.0",
"discord.js": "^12.5.1", "body-parser": "^1.20.2",
"dotenv": "^8.2.0", "cheerio": "^1.0.0-rc.10",
"elysia": "^0.7.15", "dayjs": "^1.11.10",
"minimatch": "^9.0.3", "discord.js": "^12.5.1",
"pino": "^8.15.4", "dotenv": "^8.2.0",
"typescript": "^4.1.5" "elysia": "^0.7.15",
}, "ffmpeg-static": "^5.2.0",
"devDependencies": { "lodash-es": "^4.17.21",
"@types/node": "^20.8.2", "minimatch": "^9.0.3",
"@typescript-eslint/eslint-plugin": "^6.7.4", "pino": "^8.15.4",
"@typescript-eslint/parser": "^6.7.4", "typescript": "^4.1.5"
"bun-types": "^1.0.4-canary.20231004T140131", },
"eslint": "^8.50.0", "devDependencies": {
"prettier": "^2.2.1" "@types/lodash-es": "^4.17.9",
} "@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"
}
} }

2
process-env.d.ts vendored
View File

@@ -5,6 +5,8 @@ declare global {
PORT: string; PORT: string;
CRON_LEGICA: string; CRON_LEGICA: string;
PASSWORD: string; PASSWORD: string;
TIMEZONE: string;
LEGICA_DATE_FORMAT: string;
} }
} }
} }

View File

@@ -1,12 +1,11 @@
import { Client } from "discord.js"; import { Client } from "discord.js";
import { config } from "@constants"; import { config } from "@constants";
import { CronJob } from "cron";
import { sendDiscordMessage, sendNextMessage } from "@common"; import { sendDiscordMessage, sendNextMessage } from "@common";
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { swagger } from "@elysiajs/swagger"; import { swagger } from "@elysiajs/swagger";
import { basicAuth } from "@core"; import { basicAuth, BasicAuthError } from "@core";
import pino from "pino"; import pino from "pino";
import staticPlugin from "@elysiajs/static"; import cron from "@elysiajs/cron";
const client: Client = new Client(); const client: Client = new Client();
@@ -22,28 +21,14 @@ const logger = pino(
fileTransport fileTransport
); );
const taskPlugin = new Elysia({ prefix: "/job" }) async function jobRunner() {
.state("job", null as CronJob | null) try {
.onStart(({ store }) => { await sendNextMessage(client);
client.on("ready", (): void => { } catch (err) {
if (store.job) { logger.error(err);
store.job.stop(); }
} }
store.job = new CronJob( const botPlugin = new Elysia({ prefix: "/bot" })
config.CRON_LEGICA,
() => sendNextMessage(client),
null,
true,
"utc"
);
});
})
.onBeforeHandle(({ store: { job }, set }) => {
if (!job) {
set.status = 400;
return "Job is not running.";
}
})
.use( .use(
basicAuth({ basicAuth({
users: [ users: [
@@ -55,31 +40,153 @@ const taskPlugin = new Elysia({ prefix: "/job" })
errorMessage: "Unauthorized", errorMessage: "Unauthorized",
}) })
) )
.get("/", ({ store: { job } }) => ({ .get(
running: job?.running ?? false, "/",
next: job?.nextDate().toISO(), () => ({
})) uptime: client.uptime,
.post("/", ({ store: { job }, set }) => { readyAt: client.readyAt,
if (job?.running) { readyTimestamp: client.readyTimestamp,
set.status = 400; }),
return "Task already running"; {
detail: {
summary: "Get BOT status",
},
} }
job?.start(); )
return "Task started"; .post(
}) "/",
.delete("/", ({ store: { job }, set }) => { () => {
if (!job?.running) { client.login(config.TOKEN);
set.status = 400; return "Bot logged in started";
return "Task already stopped"; },
{
detail: {
summary: "Start BOT if it is not running",
},
} }
job?.stop(); )
return "Task stopped"; .delete(
}) "/",
() => {
client.destroy();
return "Bot logged out";
},
{
detail: {
summary: "Stops the BOT.",
},
}
);
const taskPlugin = new Elysia({ prefix: "/job" })
.use(
cron({
name: "job",
pattern: config.CRON_LEGICA,
run: jobRunner,
paused: true,
timezone: config.TIMEZONE,
})
)
.onStart(
({
store: {
cron: { job },
},
}) => {
client.on("ready", (): void => {
job.resume();
});
}
)
.onBeforeHandle(
({
store: {
cron: { job },
},
set,
}) => {
if (job.isStopped()) {
set.status = 400;
return "Job is not running.";
}
}
)
.use(
basicAuth({
users: [
{
username: "admin",
password: config.PASSWORD,
},
],
errorMessage: "Unauthorized",
})
)
.get(
"/",
({
store: {
cron: { job },
},
}) => ({
running: job.isRunning() ?? false,
stopped: job.isStopped() ?? false,
next: job.nextRun()?.toISOString(),
}),
{
detail: {
summary: "Get CRON job status",
},
}
)
.post(
"/",
({
store: {
cron: { job },
},
set,
}) => {
if (job.isRunning()) {
set.status = 400;
return "Job already running";
}
job.resume();
return "Job started";
},
{
detail: {
summary: "Start CRON job if it is not running",
},
}
)
.delete(
"/",
({
store: {
cron: { job },
},
set,
}) => {
if (!job.isRunning()) {
set.status = 400;
return "Job already paused";
}
job.pause();
return "Job paused";
},
{
detail: {
summary: "Pause CRON job if it is not paused",
},
}
)
.post( .post(
"/send", "/send",
async ({ set, body }) => { async ({ set, body }) => {
try { try {
const url = body.url; const url = body?.url;
if (url) { if (url) {
await sendDiscordMessage(client, url); await sendDiscordMessage(client, url);
} else { } else {
@@ -92,21 +199,47 @@ const taskPlugin = new Elysia({ prefix: "/job" })
} }
}, },
{ {
body: t.Object({ body: t.Optional(
url: t.String(), t.Object({
}), url: t.Optional(t.String()),
})
),
detail: {
summary: "Send legica-dana post to discord channels",
},
} }
) )
.get("/log", () => Bun.file("app.log")); .get("/log", () => Bun.file("app.log"), {
detail: {
client.login(config.TOKEN); summary: "Get the error log",
},
});
const app = new Elysia() const app = new Elysia()
.onError(({ error }) => { .error({ BASIC_AUTH_ERROR: BasicAuthError })
logger.error(error); .onError(({ error, code }) => {
return new Response(error.toString()); switch (code) {
case "BASIC_AUTH_ERROR":
return new Response(error.message, {
status: 401,
headers: {
"WWW-Authenticate": `Basic${
config.realm ? ` realm="${config.realm}"` : ""
}`,
},
});
case "NOT_FOUND":
return new Response(error.message, {
status: 404,
});
default:
logger.error(error);
}
})
.get("/", () => config.APP_VERSION, {
detail: {
summary: "Get current API version",
},
}) })
.get("/", () => config.APP_VERSION)
.use( .use(
swagger({ swagger({
documentation: { documentation: {
@@ -114,13 +247,22 @@ const app = new Elysia()
title: "Legica Bot", title: "Legica Bot",
version: config.APP_VERSION, version: config.APP_VERSION,
}, },
security: [
{
type: ["basic"],
},
],
},
swaggerOptions: {
withCredentials: true,
}, },
}) })
) )
.use(staticPlugin())
.use(taskPlugin) .use(taskPlugin)
.use(botPlugin)
.listen(config.PORT); .listen(config.PORT);
client.login(config.TOKEN);
console.log( console.log(
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` `🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}`
); );

View File

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

View File

@@ -1,13 +1,31 @@
import { getFirstHtml, getImgTitle } from "@common"; import { getFirstHtml, getImgTitle } from "@common";
import { config } from "@constants";
import dayjs from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import { Client, MessageEmbed, TextChannel } from "discord.js"; import { Client, MessageEmbed, TextChannel } from "discord.js";
dayjs.extend(customParseFormat);
export async function sendDiscordMessage( export async function sendDiscordMessage(
client: Client, client: Client,
url: string url: string,
dateCheck?: dayjs.Dayjs
): Promise<void> { ): Promise<void> {
if (!url) return; if (!url) return;
const { img, title } = await getImgTitle(url); const { img, title } = await getImgTitle(url);
if (dateCheck) {
const dateRegex = /\d{1,2}.\d{1,2}.\d{4}/g;
const date = dateRegex.exec(title)?.[0];
const dayjsDate = dayjs(date, config.LEGICA_DATE_FORMAT);
if (!dateCheck.isSame(dayjsDate, "D"))
throw new Error(
`Post failed date check, date from post ${date}, date checked ${dateCheck.format(
config.LEGICA_DATE_FORMAT
)}`
);
}
client.channels.cache.forEach(async (channel) => { client.channels.cache.forEach(async (channel) => {
try { try {
if (channel.type !== "text") return null; if (channel.type !== "text") return null;
@@ -39,10 +57,6 @@ export async function sendDiscordMessage(
} }
export async function sendNextMessage(client: Client): Promise<void> { export async function sendNextMessage(client: Client): Promise<void> {
try { const href = await getFirstHtml();
const href = await getFirstHtml(); await sendDiscordMessage(client, href, dayjs());
await sendDiscordMessage(client, href);
} catch (err) {
console.error(err);
}
} }

View File

@@ -16,6 +16,8 @@ const config: ProjectConfig = {
CRON_LEGICA: process.env.CRON_LEGICA || "0 9 * * *", CRON_LEGICA: process.env.CRON_LEGICA || "0 9 * * *",
APP_VERSION: version, APP_VERSION: version,
LEGICA_URL: "https://sib.net.hr/legica-dana", LEGICA_URL: "https://sib.net.hr/legica-dana",
TIMEZONE: process.env.TIMEZONE || "utc",
LEGICA_DATE_FORMAT: process.env.LEGICA_DATE_FORMAT || "D.M.YYYY",
}; };
export { config }; export { config };

View File

@@ -22,7 +22,6 @@ export interface BasicAuthConfig {
export const basicAuth = (config: BasicAuthConfig) => export const basicAuth = (config: BasicAuthConfig) =>
new Elysia({ name: "basic-auth", seed: config }) new Elysia({ name: "basic-auth", seed: config })
.error({ BASIC_AUTH_ERROR: BasicAuthError })
.derive((ctx) => { .derive((ctx) => {
const authorization = ctx.headers?.authorization; const authorization = ctx.headers?.authorization;
if (!authorization) return { basicAuth: { isAuthed: false, username: "" } }; if (!authorization) return { basicAuth: { isAuthed: false, username: "" } };
@@ -40,19 +39,8 @@ export const basicAuth = (config: BasicAuthConfig) =>
!isPathExcluded(ctx.path, config.exclude) && !isPathExcluded(ctx.path, config.exclude) &&
ctx.request && ctx.request &&
ctx.request.method !== "OPTIONS" ctx.request.method !== "OPTIONS"
) ) {
throw new BasicAuthError(config.errorMessage ?? "Unauthorized"); 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}"` : ""
}`,
},
});
} }
}); });

View File

@@ -1 +1 @@
export { basicAuth } from "./basicAuth"; export { basicAuth, BasicAuthError } from "./basicAuth";