Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1cf854d27 | ||
|
|
972ee67f53 | ||
|
|
9ecfdef62b | ||
|
|
e56f0883cf | ||
|
|
5aa69588fb | ||
|
|
28b028d056 | ||
|
|
4ac04d4457 | ||
|
|
9d137b6b52 | ||
|
|
79110bdb1c | ||
|
|
0d2fba3883 | ||
|
|
77aa924161 | ||
|
|
b4b6f42a1a | ||
|
|
dd0b7cde36 | ||
|
|
162708812d | ||
|
|
71c9d1635b | ||
|
|
07f9b5c581 |
@@ -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)
|
||||||
|
|||||||
11
dockerfile
11
dockerfile
@@ -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"]
|
||||||
63
package.json
63
package.json
@@ -1,32 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "legica-dana",
|
"name": "legica-dana",
|
||||||
"version": "2.0.0",
|
"version": "2.0.6",
|
||||||
"main": "src/app.ts",
|
"main": "src/app.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun src/app.ts"
|
"start": "bun run src/app.ts"
|
||||||
},
|
},
|
||||||
"author": "Fran Jurmanović <fjurma12@outlook.com>",
|
"author": "Fran Jurmanović <fjurma12@outlook.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysiajs/static": "^0.7.1",
|
"@elysiajs/cron": "^0.7.0",
|
||||||
"@elysiajs/swagger": "^0.7.3",
|
"@elysiajs/static": "^0.7.1",
|
||||||
"axios": "^0.26.0",
|
"@elysiajs/swagger": "^0.7.3",
|
||||||
"body-parser": "^1.20.2",
|
"axios": "^0.26.0",
|
||||||
"cheerio": "^1.0.0-rc.10",
|
"body-parser": "^1.20.2",
|
||||||
"cron": "^3.0.0",
|
"cheerio": "^1.0.0-rc.10",
|
||||||
"discord.js": "^12.5.1",
|
"dayjs": "^1.11.10",
|
||||||
"dotenv": "^8.2.0",
|
"discord.js": "^12.5.1",
|
||||||
"elysia": "^0.7.15",
|
"dotenv": "^8.2.0",
|
||||||
"minimatch": "^9.0.3",
|
"elysia": "^0.7.15",
|
||||||
"pino": "^8.15.4",
|
"lodash-es": "^4.17.21",
|
||||||
"typescript": "^4.1.5"
|
"minimatch": "^9.0.3",
|
||||||
},
|
"pino": "^8.15.4",
|
||||||
"devDependencies": {
|
"typescript": "^4.1.5"
|
||||||
"@types/node": "^20.8.2",
|
},
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
"devDependencies": {
|
||||||
"@typescript-eslint/parser": "^6.7.4",
|
"@types/lodash-es": "^4.17.9",
|
||||||
"bun-types": "^1.0.4-canary.20231004T140131",
|
"@types/node": "^20.8.2",
|
||||||
"eslint": "^8.50.0",
|
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||||
"prettier": "^2.2.1"
|
"@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
2
process-env.d.ts
vendored
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
256
src/app.ts
256
src/app.ts
@@ -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}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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() || {};
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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}"` : ""
|
|
||||||
}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { basicAuth } from "./basicAuth";
|
export { basicAuth, BasicAuthError } from "./basicAuth";
|
||||||
|
|||||||
Reference in New Issue
Block a user