Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
985ccf3e8a | ||
|
|
01866b302e | ||
|
|
def1757371 |
28
.env.example
Normal file
28
.env.example
Normal 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
|
||||||
21
README.md
21
README.md
@@ -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)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "legica-dana",
|
"name": "legica-dana",
|
||||||
"version": "2.1.0",
|
"version": "2.1.1",
|
||||||
"main": "src/app.ts",
|
"main": "src/app.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun run src/app.ts",
|
"start": "bun run src/app.ts",
|
||||||
|
|||||||
1
process-env.d.ts
vendored
1
process-env.d.ts
vendored
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
src/app.ts
82
src/app.ts
@@ -25,7 +25,18 @@ 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" })
|
const botPlugin = new Elysia({ prefix: "/bot" })
|
||||||
@@ -93,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();
|
||||||
@@ -105,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;
|
||||||
@@ -129,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,
|
||||||
@@ -147,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;
|
||||||
@@ -168,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;
|
||||||
@@ -184,7 +248,17 @@ 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) {
|
||||||
@@ -193,7 +267,7 @@ const taskPlugin = new Elysia({ prefix: "/job" })
|
|||||||
await sendNextMessage(client);
|
await sendNextMessage(client);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
logger.error(err);
|
logger.error(err);
|
||||||
return false;
|
return false;
|
||||||
@@ -217,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, {
|
||||||
|
|||||||
@@ -6,6 +6,23 @@ 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,
|
||||||
@@ -51,14 +68,64 @@ export async function sendDiscordMessage(
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Message to channel ${channel.id} failed.`);
|
console.error(`Message to channel ${channel.id} failed.`);
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await Promise.all(promises);
|
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> {
|
||||||
|
// Get max retry attempts from config
|
||||||
|
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();
|
const href = await getFirstHtml();
|
||||||
if (!href) throw new Error("URL cannot be empty!");
|
if (!href) throw new Error("URL cannot be empty!");
|
||||||
|
|
||||||
|
// Try to send the message
|
||||||
await sendDiscordMessage(client, href, dayjs());
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user