Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/helpers/iparking_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ interface CarParksFreeSlotResponse {
}

async function fetchWithToken<T>(endpoint: string): Promise<T> {
const token = env.get("API_ACCESS_TOKEN");
const token = env.get("PARKING_API_ACCESS_TOKEN");

const controller = new AbortController();
const timeoutId = setTimeout(() => {
Expand Down
61 changes: 58 additions & 3 deletions commands/synchronize_parkings_with_slots.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { BaseCommand } from "@adonisjs/core/ace";
import { DateTime } from "luxon";

import { BaseCommand, flags } from "@adonisjs/core/ace";
import type { CommandOptions } from "@adonisjs/core/types/ace";

import { commandDiscordManager } from "../utils/command_message_manager.js";
import {
MessagePayload,
MessageType,
} from "../utils/discord_webhook_manager.js";

export default class SynchronizeParkingsWithSlots extends BaseCommand {
static commandName = "synchronize:parkings-with-slots";
static description =
Expand All @@ -10,6 +18,12 @@ export default class SynchronizeParkingsWithSlots extends BaseCommand {
startApp: true,
};

@flags.boolean({
alias: "w",
description: "Enable discord webhooks on command error",
})
declare webhook: boolean;

async run() {
this.logger.info(`Start "${this.commandName}" command`);
let slotsCommand = await this.kernel.exec("synchronize:parkings-slots", []);
Expand All @@ -22,15 +36,56 @@ export default class SynchronizeParkingsWithSlots extends BaseCommand {
);

if (parkingsCommand.exitCode !== 0) {
this.logger.error(`"${this.commandName}" failed`);
this.exitCode = parkingsCommand.exitCode;
this.error = parkingsCommand.error as unknown;
return;
}

// Retry once after synchronizing car parks and propagate the retry result.
slotsCommand = await this.kernel.exec("synchronize:parkings-slots", []);
}

this.exitCode = slotsCommand.exitCode;
this.logger.info(`"${this.commandName}" completed`);
this.error = slotsCommand.error as unknown;
}

async completed() {
if (this.exitCode === 0) {
this.logger.info(`"${this.commandName}" completed`);
return;
}

this.logger.error(`"${this.commandName}" failed`);

if (this.webhook) {
const title = `backend-topwr-parking: command ${this.commandName} has failed`;
const error: unknown = this.error;
const description =
error instanceof Error
? error.message
: typeof error === "string"
? error
: `Command exited with code ${this.exitCode}`;

Comment thread
krzys13 marked this conversation as resolved.
Resolved
try {
const payload: MessagePayload = {
messageType: MessageType.ERROR,
title,
description,
timestamp: DateTime.now(),
};
const response = await commandDiscordManager.sendMessage(payload);

if (!response.ok) {
this.logger.warning(
`Failed to send Discord notification: HTTP ${response.status}`,
);
}
} catch (notificationError) {
this.logger.warning(
`Failed to send Discord notification: ${String(notificationError)}`,
);
}
}
}
}
11 changes: 1 addition & 10 deletions start/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,5 @@ logger.info(`Parking synchronization interval: ${parkingInterval}`);

switch (parkingInterval) {
case "EVERY_MINUTE":
scheduler.command("synchronize:parkings-with-slots").everyMinute();
break;
case "EVERY_5":
scheduler.command("synchronize:parkings-with-slots").everyFiveMinutes();
break;
case "EVERY_15":
scheduler.command("synchronize:parkings-with-slots").everyFifteenMinutes();
break;
scheduler.command("synchronize:parkings-with-slots", ["-w"]).everyMinute();
}

scheduler.command("synchronize:parkings").daily();
8 changes: 8 additions & 0 deletions utils/command_message_manager.ts
Comment thread
krzys13 marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { DateTime } from "luxon";

import DiscordWebhookManager from "./discord_webhook_manager.js";

const ONE_HOUR_SECONDS = 3600;
export const commandDiscordManager = new DiscordWebhookManager(
DateTime.fromSeconds(ONE_HOUR_SECONDS),
);
85 changes: 85 additions & 0 deletions utils/discord_webhook_manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { DateTime } from "luxon";

import DiscordWebhookSender from "./discord_webhook_sender.js";
import type { DiscordWebhookRequestInit } from "./discord_webhook_sender.js";

/** Notification type determining the emoji and label in the message title. */
export enum MessageType {
INFO,
WARNING,
ERROR,
}
export interface MessagePayload {
messageType: MessageType;
title: string;
description: string;
timestamp: DateTime;
}
const MILISECONDS_IN_MINUTE = 60000;

/** Returns the title prefix, such as "❌ [ERROR]", for the notification type. */
function messageTypeToString(messageType: MessageType): string {
switch (messageType) {
case MessageType.INFO:
return "ℹ️ [INFO]";
case MessageType.WARNING:
return "⚠️ [WARNING]";
case MessageType.ERROR:
return "❌ [ERROR]";
}
}

export default class DiscordWebhookManager {
private sender = DiscordWebhookSender.getInstance();

private discordWebhookMessageBuffor: MessagePayload[];
private repetitionTimeLimit: DateTime;

public constructor(repetitionLimit: DateTime) {
this.discordWebhookMessageBuffor = [];
this.repetitionTimeLimit = repetitionLimit;
}

private hasBeenSend(payload: MessagePayload, timesSpan: DateTime): boolean {
const { timestamp: payloadTimestamp, ...payloadContent } = payload;

const hasFlag = this.discordWebhookMessageBuffor.some((value) => {
const { timestamp: valueTimestamp, ...valueContent } = value;
const minutesTimeSpan = Math.floor(
timesSpan.toMillis() / MILISECONDS_IN_MINUTE,
);
const minutesdTimeDiff = Math.floor(
payloadTimestamp.diff(valueTimestamp).toMillis() /
MILISECONDS_IN_MINUTE,
);

return (
JSON.stringify(payloadContent) === JSON.stringify(valueContent) &&
minutesdTimeDiff < minutesTimeSpan
);
});
return hasFlag;
}

public sendMessage(payload: MessagePayload) {
const requestInit: DiscordWebhookRequestInit = {
embeds: [
{
title: `${messageTypeToString(payload.messageType)}: ${payload.title}`,
description: payload.description,
timestamp: payload.timestamp.toISO() as unknown as string,
},
],
};
if (!this.hasBeenSend(payload, this.repetitionTimeLimit)) {
const res = this.sender.sendWebhook(requestInit);
this.discordWebhookMessageBuffor.push(payload);

return res;
} else {
throw new Error(
"Message to Discord was not send due to repetitionTimeLimit. Look for repetitionTimeLimit to change antyspam blocker",
);
}
}
}
82 changes: 82 additions & 0 deletions utils/discord_webhook_sender.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import env from "#start/env";

export interface DiscordWebhookRequestInit {
embeds: DiscordWebhookPayload[];
}
// Can be extended by fields supported by Discord Api.
interface DiscordWebhookPayload {
title: string;
description?: string;
/** Event time as an ISO 8601 string, e.g. "2026-11-06T12:00:00Z". */
timestamp?: string;
}

/**
* Sends Discord notifications with one embed per message.
*
* Requires the `DISORD_WEBHOOK_URL` environment variable with the webhook URL.
* The URL is read on the first send and reused for subsequent calls.
* Methods return the HTTP response; callers should check `response.ok`.
*
* @example
* ```ts
* import DiscordWebhookSender, { MessageType } from "./discord_webhook_sender.js";
* import { DateTime } from "luxon";
*
* const response = await DiscordWebhookSender.sendMessage(
* MessageType.INFO,
* "Parking synchronization",
* "Parking data has been updated.",
* DateTime.now(),
* );
*
* if (!response.ok) {
* throw new Error(`Failed to send notification: ${response.status}`);
* }
* ```
*/
export default class DiscordWebhookSender {
private static instance: DiscordWebhookSender | undefined;

private static url: string;

private constructor() {
DiscordWebhookSender.url = env.get(
"DISORD_WEBHOOK_URL",
) as unknown as string;
}
public static getInstance(): DiscordWebhookSender {
DiscordWebhookSender.instance ??= new DiscordWebhookSender();
return DiscordWebhookSender.instance;
}

/**
* Sends a POST request with an embed containing the title, body, and event time.
* Adds an emoji and an INFO, WARNING, or ERROR label to the title.
* Accepts the event time as an ISO 8601 string.
*
* @param messageType - Notification type determining the title prefix.
* @param title - Notification title without the type prefix.
* @param description - Notification body.
* @param timestamp - Event time as an ISO 8601 string, e.g. "2026-11-06T12:00:00Z".
* @returns The webhook's HTTP response. Status codes 4xx and 5xx do not throw;
* callers should check `response.ok` or `response.status`.
* @throws If the fetch request fails, for example due to a network error
* or an invalid webhook URL.
*/

public async sendWebhook(
requestInit: DiscordWebhookRequestInit,
): Promise<Response> {
DiscordWebhookSender.getInstance();

const res = await fetch(DiscordWebhookSender.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestInit),
});
return res;
}
}
Loading