14 Commits
2.0.4 ... 2.1.1

Author SHA1 Message Date
Fran Jurmanović
985ccf3e8a remove throw error on channel message fail 2025-08-09 12:40:19 +02:00
Fran Jurmanović
01866b302e add retry policy 2025-06-30 23:04:51 +02:00
Fran Jurmanović
def1757371 version increase 2024-08-16 23:08:32 +02:00
Fran Jurmanović
7e0c088fa7 increase minor version 2024-08-16 23:07:41 +02:00
Fran Jurmanović
43b4757696 change selectors to fit the new design 2024-08-16 23:00:39 +02:00
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
11 changed files with 306 additions and 55 deletions

28
.env.example Normal file
View File

@@ -0,0 +1,28 @@
# Discord Bot Configuration
# -----------------------
# Required: Your Discord bot token
TOKEN=your_discord_bot_token_here
# API Configuration
# -----------------------
# Required: Password for admin API access
PASSWORD=your_secure_password_here
# Web Server Settings
# -----------------------
# Port for the API server
PORT=3000
# Scheduling
# -----------------------
# CRON schedule for when to post (default: every day at 9 AM)
CRON_LEGICA=0 9 * * *
# Timezone for the CRON job (e.g. 'Europe/Zagreb', 'America/New_York', etc.)
TIMEZONE=utc
# Content Settings
# -----------------------
# Date format used in post titles
LEGICA_DATE_FORMAT=D.M.YYYY
# Number of hourly retry attempts if date check fails
RETRY_ATTEMPTS=3

View File

@@ -2,6 +2,27 @@
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. 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.
## Features
- Automatically posts new content from the website daily
- Built-in retry mechanism if the post isn't available yet
- Adds rating reactions (1-10) to each post
- REST API for controlling the bot
## Configuration
The bot can be configured using environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| TOKEN | Discord Bot Token | Required |
| PASSWORD | Admin password for API | Required |
| PORT | Port for the API server | 3000 |
| CRON_LEGICA | CRON schedule for posting | 0 9 * * * |
| TIMEZONE | Timezone for the CRON job | utc |
| LEGICA_DATE_FORMAT | Date format used in posts | D.M.YYYY |
| RETRY_ATTEMPTS | Number of hourly retries if date check fails | 3 |
## Documentation ## Documentation
[Documentation](https://legica.jurmanovic.com/swagger) [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,35 +1,38 @@
{ {
"name": "legica-dana", "name": "legica-dana",
"version": "2.0.4", "version": "2.1.1",
"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/cron": "^0.7.0", "license": "MIT",
"@elysiajs/static": "^0.7.1", "dependencies": {
"@elysiajs/swagger": "^0.7.3", "@elysiajs/cron": "^0.7.0",
"axios": "^0.26.0", "@elysiajs/static": "^0.7.1",
"body-parser": "^1.20.2", "@elysiajs/swagger": "^0.7.3",
"cheerio": "^1.0.0-rc.10", "axios": "^0.26.0",
"dayjs": "^1.11.10", "body-parser": "^1.20.2",
"discord.js": "^12.5.1", "cheerio": "^1.0.0-rc.10",
"dotenv": "^8.2.0", "dayjs": "^1.11.10",
"elysia": "^0.7.15", "discord.js": "^12.5.1",
"lodash-es": "^4.17.21", "dotenv": "^8.2.0",
"minimatch": "^9.0.3", "elysia": "^0.7.15",
"pino": "^8.15.4", "ffmpeg-static": "^5.2.0",
"typescript": "^4.1.5" "lodash-es": "^4.17.21",
}, "minimatch": "^9.0.3",
"devDependencies": { "pino": "^8.15.4",
"@types/lodash-es": "^4.17.9", "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"
}
} }

1
process-env.d.ts vendored
View File

@@ -7,6 +7,7 @@ declare global {
PASSWORD: string; PASSWORD: string;
TIMEZONE: string; TIMEZONE: string;
LEGICA_DATE_FORMAT: string; LEGICA_DATE_FORMAT: string;
RETRY_ATTEMPTS: string;
} }
} }
} }

View File

@@ -5,7 +5,6 @@ import { Elysia, t } from "elysia";
import { swagger } from "@elysiajs/swagger"; import { swagger } from "@elysiajs/swagger";
import { basicAuth, BasicAuthError } 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"; import cron from "@elysiajs/cron";
const client: Client = new Client(); const client: Client = new Client();
@@ -26,9 +25,69 @@ async function jobRunner() {
try { try {
await sendNextMessage(client); await sendNextMessage(client);
} catch (err) { } catch (err) {
logger.error(err); // Log detailed error information, including retry attempts
if (err instanceof Error && err.message.includes("Failed after")) {
logger.error({
msg: "All retry attempts failed in job runner",
error: err.message,
});
} else {
logger.error({
msg: "Error in job runner, no retries attempted",
error: err,
});
}
} }
} }
const botPlugin = new Elysia({ prefix: "/bot" })
.use(
basicAuth({
users: [
{
username: "admin",
password: config.PASSWORD,
},
],
errorMessage: "Unauthorized",
})
)
.get(
"/",
() => ({
uptime: client.uptime,
readyAt: client.readyAt,
readyTimestamp: client.readyTimestamp,
}),
{
detail: {
summary: "Get BOT status",
},
}
)
.post(
"/",
() => {
client.login(config.TOKEN);
return "Bot logged in started";
},
{
detail: {
summary: "Start BOT if it is not running",
},
}
)
.delete(
"/",
() => {
client.destroy();
return "Bot logged out";
},
{
detail: {
summary: "Stops the BOT.",
},
}
);
const taskPlugin = new Elysia({ prefix: "/job" }) const taskPlugin = new Elysia({ prefix: "/job" })
.use( .use(
@@ -45,6 +104,14 @@ const taskPlugin = new Elysia({ prefix: "/job" })
store: { store: {
cron: { job }, cron: { job },
}, },
}: {
store: {
cron: {
job: {
resume: () => void;
};
};
};
}) => { }) => {
client.on("ready", (): void => { client.on("ready", (): void => {
job.resume(); job.resume();
@@ -57,6 +124,17 @@ const taskPlugin = new Elysia({ prefix: "/job" })
cron: { job }, cron: { job },
}, },
set, set,
}: {
store: {
cron: {
job: {
isStopped: () => boolean;
};
};
};
set: {
status: number;
};
}) => { }) => {
if (job.isStopped()) { if (job.isStopped()) {
set.status = 400; set.status = 400;
@@ -81,6 +159,16 @@ const taskPlugin = new Elysia({ prefix: "/job" })
store: { store: {
cron: { job }, cron: { job },
}, },
}: {
store: {
cron: {
job: {
isRunning: () => boolean;
isStopped: () => boolean;
nextRun: () => Date | null;
};
};
};
}) => ({ }) => ({
running: job.isRunning() ?? false, running: job.isRunning() ?? false,
stopped: job.isStopped() ?? false, stopped: job.isStopped() ?? false,
@@ -99,6 +187,18 @@ const taskPlugin = new Elysia({ prefix: "/job" })
cron: { job }, cron: { job },
}, },
set, set,
}: {
store: {
cron: {
job: {
isRunning: () => boolean;
resume: () => void;
};
};
};
set: {
status: number;
};
}) => { }) => {
if (job.isRunning()) { if (job.isRunning()) {
set.status = 400; set.status = 400;
@@ -120,6 +220,18 @@ const taskPlugin = new Elysia({ prefix: "/job" })
cron: { job }, cron: { job },
}, },
set, set,
}: {
store: {
cron: {
job: {
isRunning: () => boolean;
pause: () => void;
};
};
};
set: {
status: number;
};
}) => { }) => {
if (!job.isRunning()) { if (!job.isRunning()) {
set.status = 400; set.status = 400;
@@ -136,18 +248,29 @@ const taskPlugin = new Elysia({ prefix: "/job" })
) )
.post( .post(
"/send", "/send",
async ({ set, body }) => { async ({
set,
body,
}: {
set: {
status: number;
};
body?: {
url?: string;
};
}) => {
try { try {
const url = body.url; const url = body?.url;
if (url) { if (url) {
await sendDiscordMessage(client, url); await sendDiscordMessage(client, url);
} else { } else {
await sendNextMessage(client); await sendNextMessage(client);
} }
return true; return true;
} catch (err) { } catch (err: unknown) {
set.status = 400; set.status = 400;
return err; logger.error(err);
return false;
} }
}, },
{ {
@@ -168,7 +291,7 @@ const taskPlugin = new Elysia({ prefix: "/job" })
}); });
const app = new Elysia() const app = new Elysia()
.error({ BASIC_AUTH_ERROR: BasicAuthError }) .error({ BASIC_AUTH_ERROR: BasicAuthError })
.onError(({ error, code }) => { .onError(({ error, code }: { error: Error; code: string }) => {
switch (code) { switch (code) {
case "BASIC_AUTH_ERROR": case "BASIC_AUTH_ERROR":
return new Response(error.message, { return new Response(error.message, {
@@ -210,8 +333,8 @@ const app = new Elysia()
}, },
}) })
) )
.use(staticPlugin())
.use(taskPlugin) .use(taskPlugin)
.use(botPlugin)
.listen(config.PORT); .listen(config.PORT);
client.login(config.TOKEN); client.login(config.TOKEN);

View File

@@ -2,10 +2,10 @@ 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 | undefined> {
const response = await axios.get(config.LEGICA_URL); 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 = $(".legica-dana").first().find("a").attr("href");
return href; return href;
} }

View File

@@ -7,8 +7,9 @@ export async function getImgTitle(href: string): Promise<Legica> {
const html = response.data; const html = response.data;
const $ = cheerio.load(html); const $ = cheerio.load(html);
const title = $(".Article-inner > h1").text(); const title = $(".article-title-container > h1").text();
const { src: img } = $(".Article-media > img").attr() || {}; const src = $(".image-holder", ".article-content").find("img").attr("src");
if (!src) throw new Error(`Image not found at ${href}.`);
return { title, img }; return { title, img: src };
} }

View File

@@ -6,12 +6,28 @@ import { Client, MessageEmbed, TextChannel } from "discord.js";
dayjs.extend(customParseFormat); dayjs.extend(customParseFormat);
/**
* Retry mechanism for failed date checks
*
* This implementation allows the bot to retry fetching and posting content
* when a date check fails. It will retry at hourly intervals for a number of attempts
* specified by the RETRY_ATTEMPTS environment variable (defaults to 3).
*
* This is useful when:
* 1. The website hasn't updated with today's post yet
* 2. There are temporary network issues
* 3. The website structure changed temporarily
*/
// Sleep function to delay between retry attempts
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
export async function sendDiscordMessage( export async function sendDiscordMessage(
client: Client, client: Client,
url: string, url: string,
dateCheck?: dayjs.Dayjs dateCheck?: dayjs.Dayjs
): Promise<void> { ): Promise<void> {
if (!url) return;
const { img, title } = await getImgTitle(url); const { img, title } = await getImgTitle(url);
if (dateCheck) { if (dateCheck) {
@@ -26,7 +42,7 @@ export async function sendDiscordMessage(
); );
} }
client.channels.cache.forEach(async (channel) => { const promises = client.channels.cache.map(async (channel) => {
try { try {
if (channel.type !== "text") return null; if (channel.type !== "text") return null;
const embeddedMessage = new MessageEmbed().setTitle(title).setImage(img); const embeddedMessage = new MessageEmbed().setTitle(title).setImage(img);
@@ -50,13 +66,66 @@ export async function sendDiscordMessage(
console.error(`Reaction ${reaction} to channel ${channel.id} failed.`); console.error(`Reaction ${reaction} to channel ${channel.id} failed.`);
} }
} }
} catch { } catch (err) {
console.error(`Message to channel ${channel.id} failed.`); console.error(`Message to channel ${channel.id} failed.`);
} }
}); });
await Promise.all(promises);
} }
/**
* Fetches and sends the next legica-dana post to all Discord channels
*
* This function implements a retry mechanism that will:
* 1. Try to fetch and post the latest content
* 2. If it fails (especially due to date check), wait for 1 hour
* 3. Retry up to the number of times specified in RETRY_ATTEMPTS env var
*
* @param client The Discord client used to send messages
* @throws Error if all retry attempts fail
*/
export async function sendNextMessage(client: Client): Promise<void> { export async function sendNextMessage(client: Client): Promise<void> {
const href = await getFirstHtml(); // Get max retry attempts from config
await sendDiscordMessage(client, href, dayjs()); const maxRetries = config.RETRY_ATTEMPTS;
let attempts = 0;
let lastError: Error | null = null;
// Keep trying until we've reached max attempts
while (attempts < maxRetries) {
try {
// Get the URL of the latest post
const href = await getFirstHtml();
if (!href) throw new Error("URL cannot be empty!");
// Try to send the message
await sendDiscordMessage(client, href, dayjs());
// If successful, return
return;
} catch (error: unknown) {
attempts++;
const typedError = error instanceof Error ? error : new Error(String(error));
lastError = typedError;
// Log the retry attempt
console.error(
`Attempt ${attempts}/${maxRetries} failed: ${typedError.message}`
);
// If we've reached max attempts, throw the last error
if (attempts >= maxRetries) {
throw new Error(
`Failed after ${attempts} attempts. Last error: ${
lastError?.message || "Unknown error"
}`
);
}
// Wait for 1 hour before retrying (3600000 ms)
console.log(
`Waiting 1 hour before retry attempt ${attempts + 1}/${maxRetries}...`
);
await sleep(3600000);
}
}
} }

View File

@@ -5,6 +5,7 @@ dotenv();
type Config = { type Config = {
APP_VERSION: string; APP_VERSION: string;
LEGICA_URL: string; LEGICA_URL: string;
RETRY_ATTEMPTS: number;
}; };
export type ProjectConfig = Config & NodeJS.ProcessEnv; export type ProjectConfig = Config & NodeJS.ProcessEnv;
@@ -18,6 +19,7 @@ const config: ProjectConfig = {
LEGICA_URL: "https://sib.net.hr/legica-dana", LEGICA_URL: "https://sib.net.hr/legica-dana",
TIMEZONE: process.env.TIMEZONE || "utc", TIMEZONE: process.env.TIMEZONE || "utc",
LEGICA_DATE_FORMAT: process.env.LEGICA_DATE_FORMAT || "D.M.YYYY", LEGICA_DATE_FORMAT: process.env.LEGICA_DATE_FORMAT || "D.M.YYYY",
RETRY_ATTEMPTS: parseInt(process.env.RETRY_ATTEMPTS || "3", 10),
}; };
export { config }; export { config };