From 01866b302e33803db7ac8499ec174474c517a02c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Mon, 30 Jun 2025 23:04:51 +0200 Subject: [PATCH] add retry policy --- .env.example | 28 +++++++++++ README.md | 21 ++++++++ process-env.d.ts | 1 + src/app.ts | 82 ++++++++++++++++++++++++++++++-- src/common/sendDiscordMessage.ts | 74 ++++++++++++++++++++++++++-- src/constants/config.ts | 2 + 6 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5806bda --- /dev/null +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 8b36a8c..ba86436 100644 --- a/README.md +++ b/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. +## 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](https://legica.jurmanovic.com/swagger) diff --git a/process-env.d.ts b/process-env.d.ts index 7798f53..35c377a 100644 --- a/process-env.d.ts +++ b/process-env.d.ts @@ -7,6 +7,7 @@ declare global { PASSWORD: string; TIMEZONE: string; LEGICA_DATE_FORMAT: string; + RETRY_ATTEMPTS: string; } } } diff --git a/src/app.ts b/src/app.ts index c9a4615..e404bc8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -25,7 +25,18 @@ async function jobRunner() { try { await sendNextMessage(client); } 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" }) @@ -93,6 +104,14 @@ const taskPlugin = new Elysia({ prefix: "/job" }) store: { cron: { job }, }, + }: { + store: { + cron: { + job: { + resume: () => void; + }; + }; + }; }) => { client.on("ready", (): void => { job.resume(); @@ -105,6 +124,17 @@ const taskPlugin = new Elysia({ prefix: "/job" }) cron: { job }, }, set, + }: { + store: { + cron: { + job: { + isStopped: () => boolean; + }; + }; + }; + set: { + status: number; + }; }) => { if (job.isStopped()) { set.status = 400; @@ -129,6 +159,16 @@ const taskPlugin = new Elysia({ prefix: "/job" }) store: { cron: { job }, }, + }: { + store: { + cron: { + job: { + isRunning: () => boolean; + isStopped: () => boolean; + nextRun: () => Date | null; + }; + }; + }; }) => ({ running: job.isRunning() ?? false, stopped: job.isStopped() ?? false, @@ -147,6 +187,18 @@ const taskPlugin = new Elysia({ prefix: "/job" }) cron: { job }, }, set, + }: { + store: { + cron: { + job: { + isRunning: () => boolean; + resume: () => void; + }; + }; + }; + set: { + status: number; + }; }) => { if (job.isRunning()) { set.status = 400; @@ -168,6 +220,18 @@ const taskPlugin = new Elysia({ prefix: "/job" }) cron: { job }, }, set, + }: { + store: { + cron: { + job: { + isRunning: () => boolean; + pause: () => void; + }; + }; + }; + set: { + status: number; + }; }) => { if (!job.isRunning()) { set.status = 400; @@ -184,7 +248,17 @@ const taskPlugin = new Elysia({ prefix: "/job" }) ) .post( "/send", - async ({ set, body }) => { + async ({ + set, + body, + }: { + set: { + status: number; + }; + body?: { + url?: string; + }; + }) => { try { const url = body?.url; if (url) { @@ -193,7 +267,7 @@ const taskPlugin = new Elysia({ prefix: "/job" }) await sendNextMessage(client); } return true; - } catch (err) { + } catch (err: unknown) { set.status = 400; logger.error(err); return false; @@ -217,7 +291,7 @@ const taskPlugin = new Elysia({ prefix: "/job" }) }); const app = new Elysia() .error({ BASIC_AUTH_ERROR: BasicAuthError }) - .onError(({ error, code }) => { + .onError(({ error, code }: { error: Error; code: string }) => { switch (code) { case "BASIC_AUTH_ERROR": return new Response(error.message, { diff --git a/src/common/sendDiscordMessage.ts b/src/common/sendDiscordMessage.ts index a73df4b..b56ff0c 100644 --- a/src/common/sendDiscordMessage.ts +++ b/src/common/sendDiscordMessage.ts @@ -6,6 +6,23 @@ import { Client, MessageEmbed, TextChannel } from "discord.js"; 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 => + new Promise((resolve) => setTimeout(resolve, ms)); + export async function sendDiscordMessage( client: Client, url: string, @@ -57,8 +74,59 @@ export async function sendDiscordMessage( 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 { - const href = await getFirstHtml(); - if (!href) throw new Error("URL cannot be empty!"); - await sendDiscordMessage(client, href, dayjs()); + // 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(); + 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); + } + } } diff --git a/src/constants/config.ts b/src/constants/config.ts index a414150..10e4b15 100644 --- a/src/constants/config.ts +++ b/src/constants/config.ts @@ -5,6 +5,7 @@ dotenv(); type Config = { APP_VERSION: string; LEGICA_URL: string; + RETRY_ATTEMPTS: number; }; export type ProjectConfig = Config & NodeJS.ProcessEnv; @@ -18,6 +19,7 @@ const config: ProjectConfig = { LEGICA_URL: "https://sib.net.hr/legica-dana", TIMEZONE: process.env.TIMEZONE || "utc", LEGICA_DATE_FORMAT: process.env.LEGICA_DATE_FORMAT || "D.M.YYYY", + RETRY_ATTEMPTS: parseInt(process.env.RETRY_ATTEMPTS || "3", 10), }; export { config };