diff --git a/apps/agent/.env.example b/apps/agent/.env.example index e14893d1..ec54d708 100644 --- a/apps/agent/.env.example +++ b/apps/agent/.env.example @@ -16,8 +16,5 @@ BLOCK_NUMBER_BLOCKMETA_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuaWNlIjoidH # Path to the agent YAML configuration file EBO_AGENT_CONFIG_FILE_PATH="./config.example.yml" -# Discord bot token for notifications -DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN - -# Discord channel ID for notifications -DISCORD_CHANNEL_ID=YOUR_DISCORD_CHANNEL_ID +# Discord webhook notifications +DISCORD_WEBHOOK=YOUR_DISCORD_WEBHOOK diff --git a/apps/agent/README.md b/apps/agent/README.md index 450aa4d4..53b33ccc 100644 --- a/apps/agent/README.md +++ b/apps/agent/README.md @@ -29,16 +29,15 @@ cp .env.example .env **Environment Variables**: -| Variable | Description | Required | -| ------------------------------- | ------------------------------------------------------------------------- | -------- | -| `PROTOCOL_PROVIDER_PRIVATE_KEY` | Private key for the Protocol Provider | Yes | -| `PROTOCOL_PROVIDER_L1_RPC_URLS` | Comma-separated URLs for Layer 1 RPC endpoints | Yes | -| `PROTOCOL_PROVIDER_L2_RPC_URLS` | Comma-separated URLs for Layer 2 RPC endpoints | Yes | -| `BLOCK_NUMBER_RPC_URLS_MAP` | JSON map of chain IDs to arrays of RPC URLs for Block Number calculations | Yes | -| `BLOCK_NUMBER_BLOCKMETA_TOKEN` | Bearer token for the Blockmeta service (see notes below on how to obtain) | Yes | -| `EBO_AGENT_CONFIG_FILE_PATH` | Path to the agent YAML configuration file | Yes | -| `DISCORD_BOT_TOKEN` | Your Discord bot’s token | Yes | -| `DISCORD_CHANNEL_ID` | Discord channel ID for notifications | Yes | +| Variable | Description | Required | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `PROTOCOL_PROVIDER_PRIVATE_KEY` | Private key for the Protocol Provider | Yes | +| `PROTOCOL_PROVIDER_L1_RPC_URLS` | Comma-separated URLs for Layer 1 RPC endpoints | Yes | +| `PROTOCOL_PROVIDER_L2_RPC_URLS` | Comma-separated URLs for Layer 2 RPC endpoints | Yes | +| `BLOCK_NUMBER_RPC_URLS_MAP` | JSON map of chain IDs to arrays of RPC URLs for Block Number calculations | Yes | +| `BLOCK_NUMBER_BLOCKMETA_TOKEN` | Bearer token for the Blockmeta service (see notes below on how to obtain) | Yes | +| `EBO_AGENT_CONFIG_FILE_PATH` | Path to the agent YAML configuration file | Yes | +| `DISCORD_WEBHOOK` | Your Discord channel webhook for notifications [Learn how to create a Discord webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) | No | **Notes:** diff --git a/apps/agent/config.example.yml b/apps/agent/config.example.yml index da0268b8..60f0e14e 100644 --- a/apps/agent/config.example.yml +++ b/apps/agent/config.example.yml @@ -31,3 +31,6 @@ processor: requestModule: "0x1234567890123456789012345678901234567890" # Address of the Request module responseModule: "0x1234567890123456789012345678901234567890" # Address of the Response module escalationModule: "0x1234567890123456789012345678901234567890" # Address of the Escalation module + +notifier: + discordDefaultAvatarUrl: "https://cryptologos.cc/logos/the-graph-grt-logo.png" # Default avatar URL for Discord notifications diff --git a/apps/agent/src/config/index.ts b/apps/agent/src/config/index.ts index 3577d4df..8cf5d1a6 100644 --- a/apps/agent/src/config/index.ts +++ b/apps/agent/src/config/index.ts @@ -67,6 +67,5 @@ export const config = { }, }, processor: { ...configData.processor }, - DISCORD_BOT_TOKEN: envData.DISCORD_BOT_TOKEN, - DISCORD_CHANNEL_ID: envData.DISCORD_CHANNEL_ID, + DISCORD_WEBHOOK: envData.DISCORD_WEBHOOK, } as const; diff --git a/apps/agent/src/config/schemas.ts b/apps/agent/src/config/schemas.ts index 3d6328f8..1aab7da1 100644 --- a/apps/agent/src/config/schemas.ts +++ b/apps/agent/src/config/schemas.ts @@ -30,8 +30,7 @@ export const envSchema = z.object({ BLOCK_NUMBER_RPC_URLS_MAP: stringToJSONSchema.pipe(chainRpcUrlSchema), BLOCK_NUMBER_BLOCKMETA_TOKEN: z.string(), EBO_AGENT_CONFIG_FILE_PATH: z.string(), - DISCORD_BOT_TOKEN: z.string(), - DISCORD_CHANNEL_ID: z.string(), + DISCORD_WEBHOOK: z.string(), }); const addressSchema = z.string().refine((address) => isAddress(address)); diff --git a/apps/agent/src/index.ts b/apps/agent/src/index.ts index aca5dcfd..a39c8571 100644 --- a/apps/agent/src/index.ts +++ b/apps/agent/src/index.ts @@ -1,9 +1,9 @@ import { inspect } from "util"; import { isNativeError } from "util/types"; import { + DiscordNotifier, EboActorsManager, EboProcessor, - NotificationService, ProtocolProvider, } from "@ebo-agent/automated-dispute"; import { BlockNumberService } from "@ebo-agent/blocknumber"; @@ -39,17 +39,11 @@ const main = async (): Promise => { logger.debug("Protocol provider initialized."); - const discordConfig = { - discordBotToken: config.DISCORD_BOT_TOKEN, - discordChannelId: config.DISCORD_CHANNEL_ID, - }; - logger.debug("Initializing notifier..."); - logger.debug(stringify(discordConfig)); - // const notifier = await DiscordNotifier.create(discordConfig, logger); - // FIXME: during E2E DiscordNotifier is not able to start even if setting a valid token - const notifier = { notifyError: (_e, _ctx) => {} } as NotificationService; + const notifier = new DiscordNotifier(config.DISCORD_WEBHOOK, logger); + + logger.debug("Notifier initialized..."); const actorsManager = new EboActorsManager(); diff --git a/apps/agent/test/e2e/scenarios/01_happy_path/index.spec.ts b/apps/agent/test/e2e/scenarios/01_happy_path/index.spec.ts index e676a143..a2d5cf37 100644 --- a/apps/agent/test/e2e/scenarios/01_happy_path/index.spec.ts +++ b/apps/agent/test/e2e/scenarios/01_happy_path/index.spec.ts @@ -236,8 +236,7 @@ describe.sequential("single agent", () => { BLOCK_NUMBER_RPC_URLS_MAP: new Map([ [PROTOCOL_L2_CHAIN_ID, [PROTOCOL_L2_LOCAL_URL]], ]), - DISCORD_BOT_TOKEN: "", - DISCORD_CHANNEL_ID: "", + DISCORD_WEBHOOK: "", }, }); @@ -514,8 +513,7 @@ describe.sequential("single agent", () => { BLOCK_NUMBER_RPC_URLS_MAP: new Map([ [PROTOCOL_L2_CHAIN_ID, [PROTOCOL_L2_LOCAL_URL]], ]), - DISCORD_BOT_TOKEN: "", - DISCORD_CHANNEL_ID: "", + DISCORD_WEBHOOK: "", }, }); diff --git a/packages/automated-dispute/src/config.ts b/packages/automated-dispute/src/config.ts index bea871fd..14d9f5c4 100644 --- a/packages/automated-dispute/src/config.ts +++ b/packages/automated-dispute/src/config.ts @@ -1,8 +1,7 @@ import { z } from "zod"; const ConfigSchema = z.object({ - DISCORD_BOT_TOKEN: z.string().min(1), - DISCORD_CHANNEL_ID: z.string().min(1), + DISCORD_WEBHOOK: z.string().url().optional(), }); export const config = ConfigSchema.parse(process.env); diff --git a/packages/automated-dispute/src/exceptions/errorHandler.ts b/packages/automated-dispute/src/exceptions/errorHandler.ts index 532f7144..1ca073d4 100644 --- a/packages/automated-dispute/src/exceptions/errorHandler.ts +++ b/packages/automated-dispute/src/exceptions/errorHandler.ts @@ -24,7 +24,11 @@ export class ErrorHandler { this.logger.error(`Error executing custom action: ${actionError}`); } finally { if (strategy.shouldNotify) { - await this.notificationService.notifyError(error, context); + await this.notificationService.sendError( + "An error occurred in the custom contract", + context, + error, + ); } if (strategy.shouldReenqueue && context.reenqueueEvent) { diff --git a/packages/automated-dispute/src/exceptions/index.ts b/packages/automated-dispute/src/exceptions/index.ts index 9f43040a..efd00135 100644 --- a/packages/automated-dispute/src/exceptions/index.ts +++ b/packages/automated-dispute/src/exceptions/index.ts @@ -4,6 +4,8 @@ export * from "./decodeLogDataFailure.js"; export * from "./eboActor/index.js"; export * from "./eboProcessor/index.js"; export * from "./eboRegistry/index.js"; + +export * from "./notificationFailure.exception.js"; export * from "./errorFactory.js"; export * from "./invalidAccountOnClient.exception.js"; export * from "./invalidActorState.exception.js"; diff --git a/packages/automated-dispute/src/exceptions/notificationFailure.exception.ts b/packages/automated-dispute/src/exceptions/notificationFailure.exception.ts new file mode 100644 index 00000000..1ea629f2 --- /dev/null +++ b/packages/automated-dispute/src/exceptions/notificationFailure.exception.ts @@ -0,0 +1,6 @@ +export class NotificationFailureException extends Error { + constructor(message: string) { + super(`Failed to send notification: ${message}`); + this.name = "NotificationFailureException"; + } +} diff --git a/packages/automated-dispute/src/interfaces/notificationService.ts b/packages/automated-dispute/src/interfaces/notificationService.ts index 499e0a59..9e881f35 100644 --- a/packages/automated-dispute/src/interfaces/notificationService.ts +++ b/packages/automated-dispute/src/interfaces/notificationService.ts @@ -1,13 +1,75 @@ /** - * Interface representing a notification service capable of sending error notifications. + * Represents a notification message. + */ +export interface IMessage { + /** + * The main content of the message. + */ + title: string; + /** + * An optional subtitle for the message. + */ + subtitle?: string; + /** + * An optional description providing more details. + */ + description?: string; + /** + * The username to display as the sender. + */ + username?: string; + /** + * The URL of the avatar image to display. + */ + avatarUrl?: string; + /** + * An optional URL associated with the message. + */ + actionUrl?: string; +} + +/** + * Interface representing a notification service capable of sending notifications. */ export interface NotificationService { /** - * Sends an error notification along with optional contextual information. + * Sends a notification message. + * + * @param {IMessage} message - The message to send. + * @returns {Promise} A promise that resolves when the message is sent. + */ + send(message: IMessage): Promise; + + /** + * Sends a notification message and throws an exception if sending fails. + * + * @param {IMessage} message - The message to send. + * @returns {Promise} A promise that resolves when the message is sent. + * @throws {NotificationFailureException} If sending the message fails. + */ + sendOrThrow(message: IMessage): Promise; + + /** + * Creates an IMessage from an error. + * + * @param {string} defaultMessage - A default message describing the error context. + * @param {unknown} [context] - Additional context for the error. + * @param {unknown} [err] - The error object. + * @returns {IMessage} An IMessage object ready to be sent via the notifier. + */ + createErrorMessage(defaultMessage: string, context?: unknown, err?: unknown): IMessage; + + /** + * Sends an error notification message. * - * @param error - The error object containing information about the error that occurred. - * @param context - Additional context or data related to the error - * @returns A promise that resolves when the notification process is complete. + * @param {string} defaultMessage - A default message describing the error context. + * @param {Record} [context] - Additional context for the error. + * @param {unknown} [err] - The error object. + * @returns {Promise} A promise that resolves when the message is sent. */ - notifyError(error: Error, context: any): Promise; + sendError( + defaultMessage: string, + context?: Record, + err?: unknown, + ): Promise; } diff --git a/packages/automated-dispute/src/services/discordNotifier.ts b/packages/automated-dispute/src/services/discordNotifier.ts index d274e882..9872e342 100644 --- a/packages/automated-dispute/src/services/discordNotifier.ts +++ b/packages/automated-dispute/src/services/discordNotifier.ts @@ -1,98 +1,152 @@ -import { ILogger } from "@ebo-agent/shared"; -import { Client, IntentsBitField, TextChannel } from "discord.js"; -import { stringify } from "viem"; +import { isNativeError } from "util/types"; +import type { APIEmbed, JSONEncodable } from "discord.js"; +import { ILogger, stringify } from "@ebo-agent/shared"; +import { WebhookClient, WebhookMessageCreateOptions } from "discord.js"; -import { NotificationService } from "../interfaces/index.js"; +import { NotificationFailureException } from "../exceptions/index.js"; +import { IMessage, NotificationService } from "../interfaces/index.js"; -interface DiscordNotifierConfig { - discordBotToken: string; - discordChannelId: string; -} +export type WebhookMessage = WebhookMessageCreateOptions & { + content: string; +}; /** - * A notifier class for sending error notifications to a Discord channel. + * A notifier class for sending notifications to Discord via webhooks. */ export class DiscordNotifier implements NotificationService { - private client: Client; - private config: DiscordNotifierConfig; - private logger: ILogger; + private client: WebhookClient; - private constructor(client: Client, config: DiscordNotifierConfig, logger: ILogger) { - this.client = client; - this.config = config; - this.logger = logger; + /** + * Creates an instance of DiscordNotifier. + * + * @param {string} url - The Discord webhook URL. + * @param {ILogger} logger - An injected logger instance implementing ILogger. + * @param {string} [defaultAvatarUrl] - The default avatar URL to use if none is provided. + */ + constructor( + url: string, + private readonly logger: ILogger, + private readonly defaultAvatarUrl?: string, + ) { + this.client = new WebhookClient({ url }); + this.defaultAvatarUrl = defaultAvatarUrl; } /** - * Creates an instance of the DiscordNotifier. - * @param {DiscordNotifierConfig} config - The configuration object for the DiscordNotifier. - * @param {ILogger} logger - The logger instance. - * @returns {Promise} A promise that resolves to a DiscordNotifier instance. + * Sends a notification message to Discord. Errors are logged but not thrown. + * + * @param {IMessage} message - The message to send. + * @returns {Promise} A promise that resolves when the message is sent. */ - public static async create( - config: DiscordNotifierConfig, - logger: ILogger, - ): Promise { - const intents = new IntentsBitField().add( - IntentsBitField.Flags.Guilds, - IntentsBitField.Flags.GuildMessages, - ); - const client = new Client({ intents }); - + async send(message: IMessage): Promise { + const webhookMessage = this.buildWebhookMessage(message); try { - await client.login(config.discordBotToken); - - await new Promise((resolve, reject) => { - client.once("ready", () => { - logger.info("Discord bot is ready"); - - resolve(); - }); - - client.once("error", (error: Error) => { - reject(error); - }); - }); + await this.client.send(webhookMessage); } catch (error) { - logger.error(`Failed to initialize Discord notifier: ${error}`); - - throw error; + this.logger.error( + `Failed to send Discord notification: ${ + error instanceof Error ? error.message : String(error) + }`, + ); } - - return new DiscordNotifier(client, config, logger); } /** - * Sends an error notification to the specified Discord channel. - * @param {Error} error - The error to notify about. - * @param {any} context - Additional context information. + * Sends a notification message to Discord. Throws an exception if sending fails. + * + * @param {IMessage} message - The message to send. * @returns {Promise} A promise that resolves when the message is sent. + * @throws {NotificationFailureException} If sending the message fails. */ - public async notifyError(error: Error, context: any): Promise { + async sendOrThrow(message: IMessage): Promise { + const webhookMessage = this.buildWebhookMessage(message); try { - const channel = await this.client.channels.fetch(this.config.discordChannelId); - if (!channel || !channel.isTextBased()) { - throw new Error("Discord channel not found or is not text-based"); - } - const errorMessage = this.formatErrorMessage(error, context); - await (channel as TextChannel).send(errorMessage); - this.logger.info("Error notification sent to Discord"); - } catch (err) { - this.logger.error(`Failed to send error notification to Discord: ${err}`); + await this.client.send(webhookMessage); + } catch (error) { + throw new NotificationFailureException("An error occurred with the Discord client."); } } /** - * Formats the error message to be sent to Discord. - * @param {Error} error - The error object. - * @param {any} context - Additional context information. - * @returns {string} The formatted error message. + * Builds a Discord webhook message from the given IMessage. + * + * @param {IMessage} message - The message details. + * @returns {WebhookMessage} The built Discord webhook message. + */ + private buildWebhookMessage(message: IMessage): WebhookMessage { + return { + username: message.username || "EBO Agent", + content: message.title, + avatarURL: message.avatarUrl || this.defaultAvatarUrl, + embeds: + message.subtitle || message.description + ? [this.buildWebhookMessageEmbed(message)] + : undefined, + } as WebhookMessage; + } + + /** + * Builds a Discord embed message from the given IMessage. + * + * @param {IMessage} message - The message details. + * @returns {APIEmbed | JSONEncodable} The built Discord embed message. + */ + private buildWebhookMessageEmbed(message: IMessage): APIEmbed | JSONEncodable { + return { + title: message.subtitle, + description: message.description, + color: 2326507, + url: message.actionUrl, + }; + } + + /** + * Creates an IMessage from an error. + * + * @param {string} defaultMessage - A default message describing the error context. + * @param {unknown} [context={}] - Additional context for the error. + * @param {unknown} [err] - The error object. + * @returns {IMessage} An IMessage object ready to be sent via the notifier. + */ + public createErrorMessage( + defaultMessage: string, + context: unknown = {}, + err?: unknown, + ): IMessage { + const contextObject = typeof context === "object" && context !== null ? context : {}; + if (isNativeError(err)) { + return { + title: `**Error:** ${err.name} - ${err.message}`, + description: `**Context:**\n\`\`\`json\n${stringify({ + ...contextObject, + stack: err.stack, + })}\n\`\`\``, + }; + } else { + return { + title: `**Error:** ${defaultMessage}`, + description: `**Context:**\n\`\`\`json\n${stringify({ + ...contextObject, + error: err ? stringify(err) : undefined, + })}\n\`\`\``, + }; + } + } + + /** + * Sends an error notification message to Discord. + * + * @param {string} defaultMessage - A default message describing the error context. + * @param {Record} [context={}] - Additional context for the error. + * @param {unknown} [err] - The error object. + * @returns {Promise} A promise that resolves when the message is sent. */ - private formatErrorMessage(error: Error, context: unknown): string { - return `**Error:** ${error.name} - ${error.message}\n**Context:**\n\`\`\`json\n${stringify( - context, - null, - 2, - )}\n\`\`\``; + public async sendError( + defaultMessage: string, + context: Record = {}, + err?: unknown, + ): Promise { + const errorMessage = this.createErrorMessage(defaultMessage, context, err); + await this.send(errorMessage); } } diff --git a/packages/automated-dispute/src/services/eboProcessor.ts b/packages/automated-dispute/src/services/eboProcessor.ts index 9103ec6b..78c608e3 100644 --- a/packages/automated-dispute/src/services/eboProcessor.ts +++ b/packages/automated-dispute/src/services/eboProcessor.ts @@ -62,9 +62,7 @@ export class EboProcessor { } catch (err) { this.logger.error(`Unhandled error during the event loop: ${err}`); - await this.notifier.notifyError(err as Error, { - message: "Unhandled error during the event loop", - }); + await this.notifier.sendError("Unhandled error during the event loop", {}, err); clearInterval(this.eventsInterval); @@ -154,15 +152,12 @@ export class EboProcessor { this.lastCheckedBlock = lastBlock.number; } catch (err) { if (isNativeError(err)) { - this.logger.error(`Sync failed: ` + `${err.message}\n\n` + `${err.stack}`); + this.logger.error(`Sync failed: ${err.message}\n\n${err.stack}`); } else { this.logger.error(`Sync failed: ${err}`); } - await this.notifier.notifyError(err as Error, { - message: "Error during synchronization", - stack: isNativeError(err) ? err.stack : undefined, - }); + await this.notifier.sendError("Error during synchronization", {}, err); } } @@ -264,7 +259,7 @@ export class EboProcessor { lastBlock: Block, ) { const firstEvent = events[0]; - const actor = this.getOrCreateActor(requestId, firstEvent); + const actor = await this.getOrCreateActor(requestId, firstEvent); if (!actor) { this.logger.warn(droppingUnhandledEventsWarning(requestId)); @@ -304,7 +299,7 @@ export class EboProcessor { * @param firstEvent an event to create an actor if it does not exist * @returns the actor handling the specified request */ - private getOrCreateActor(requestId: RequestId, firstEvent?: EboEvent) { + private async getOrCreateActor(requestId: RequestId, firstEvent?: EboEvent) { const actor = this.actorsManager.getActor(requestId); if (actor) return actor; @@ -327,11 +322,11 @@ export class EboProcessor { } else { this.logger.warn(`Chain ${chainId} not supported by the agent. Skipping...`); - this.notifier.notifyError(new Error(`Unsupported chain`), { - message: `Chain ${chainId} not supported by the agent. Skipping...`, - chainId, - requestId, - }); + await this.notifier.sendError( + `Chain ${chainId} not supported by the agent. Skipping...`, + { chainId, requestId }, + new Error("Unsupported chain"), + ); return null; } @@ -359,17 +354,14 @@ export class EboProcessor { return actor; } - private onActorError(requestId: RequestId, error: Error) { + private async onActorError(requestId: RequestId, error: Error) { this.logger.error( `Critical error. Actor event handling request ${requestId} ` + `threw a non-recoverable error: ${error.message}\n\n` + `The request ${requestId} will stop being tracked by the system.`, ); - this.notifier.notifyError(error, { - message: `Actor error for request ${requestId}`, - requestId, - }); + await this.notifier.sendError(`Actor error for request ${requestId}`, { requestId }, error); this.terminateActor(requestId); } @@ -440,13 +432,11 @@ export class EboProcessor { `Could not create a request for epoch ${epoch} and chain ${chain}.`, ); - this.logger.error(err as Error); - - await this.notifier.notifyError(err as Error, { - message: `Could not create a request for epoch ${epoch} and chain ${chain}.`, - epoch, - chain, - }); + await this.notifier.sendError( + `Could not create a request for epoch ${epoch} and chain ${chain}.`, + { epoch, chain }, + err, + ); } }); @@ -454,13 +444,13 @@ export class EboProcessor { this.logger.info("Missing requests created."); } catch (err) { - this.logger.error( - `Requests creation missing: ${isNativeError(err) ? err.message : err}`, - ); - await this.notifier.notifyError(err as Error, { - message: "Error creating missing requests", - epoch, - }); + if (isNativeError(err)) { + this.logger.error(`Requests creation failed: ${err.message}`); + } else { + this.logger.error(`Requests creation failed: ${err}`); + } + + await this.notifier.sendError("Error creating missing requests", { epoch }, err); } } @@ -469,7 +459,7 @@ export class EboProcessor { * * @param requestId the ID of the request the actor is handling */ - private terminateActor(requestId: RequestId) { + private async terminateActor(requestId: RequestId) { this.logger.info(`Terminating actor handling request ${requestId}...`); const deletedActor = this.actorsManager.deleteActor(requestId); @@ -478,10 +468,12 @@ export class EboProcessor { this.logger.info(`Actor handling request ${requestId} was terminated.`); } else { this.logger.warn(alreadyDeletedActorWarning(requestId)); - this.notifier.notifyError(new Error(`Actor already deleted`), { - message: `Actor handling request ${requestId} was already terminated.`, - requestId, - }); + + await this.notifier.sendError( + `Actor handling request ${requestId} was already terminated.`, + { requestId }, + new Error("Actor already deleted"), + ); } } } diff --git a/packages/automated-dispute/src/types/errorTypes.ts b/packages/automated-dispute/src/types/errorTypes.ts index 9e574334..a807c0c5 100644 --- a/packages/automated-dispute/src/types/errorTypes.ts +++ b/packages/automated-dispute/src/types/errorTypes.ts @@ -17,6 +17,7 @@ export type TimeBasedError = BaseErrorStrategy; export type ErrorHandlingStrategy = BaseErrorStrategy; export interface ErrorContext { + [key: string]: unknown; request: Request; response?: Response; dispute?: Dispute; diff --git a/packages/automated-dispute/tests/mocks/eboProcessor.mocks.ts b/packages/automated-dispute/tests/mocks/eboProcessor.mocks.ts index b93ceb53..89cd81ab 100644 --- a/packages/automated-dispute/tests/mocks/eboProcessor.mocks.ts +++ b/packages/automated-dispute/tests/mocks/eboProcessor.mocks.ts @@ -4,8 +4,7 @@ import { vi } from "vitest"; import { NotificationService } from "../../src/index.js"; import { ProtocolProvider } from "../../src/providers/index.js"; -import { EboProcessor } from "../../src/services"; -import { EboActorsManager } from "../../src/services/index.js"; +import { EboActorsManager, EboProcessor } from "../../src/services/index.js"; import { AccountingModules } from "../../src/types/prophet.js"; import { DEFAULT_MOCKED_PROTOCOL_CONTRACTS, @@ -65,7 +64,15 @@ export function buildEboProcessor( if (!notifier) { notifier = { - notifyError: vi.fn().mockResolvedValue(undefined), + send: vi.fn(), + sendOrThrow: vi.fn(), + sendError: vi.fn(), + createErrorMessage: vi.fn((defaultMessage, context) => { + return { + title: defaultMessage, + description: JSON.stringify(context, null, 2), + }; + }), }; } diff --git a/packages/automated-dispute/tests/services/discordNotifier.spec.ts b/packages/automated-dispute/tests/services/discordNotifier.spec.ts index 7fd6cb36..41ed90c6 100644 --- a/packages/automated-dispute/tests/services/discordNotifier.spec.ts +++ b/packages/automated-dispute/tests/services/discordNotifier.spec.ts @@ -1,111 +1,168 @@ -import { ILogger } from "@ebo-agent/shared"; -import { Client, IntentsBitField } from "discord.js"; +import { ILogger, stringify } from "@ebo-agent/shared"; +import { WebhookClient } from "discord.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DiscordNotifier } from "../../src/services/index.js"; -import mocks from "../mocks/index.js"; - -vi.mock("discord.js", async () => { - const actualDiscord = await vi.importActual("discord.js"); - - const MockClient = vi.fn(() => ({ - login: vi.fn().mockResolvedValue(undefined), - once: vi.fn((event, callback) => { - if (event === "ready") { - callback(); - } - }), - channels: { - fetch: vi.fn().mockResolvedValue({ - isTextBased: () => true, - send: vi.fn().mockResolvedValue(undefined), - }), - }, - })); +import { NotificationFailureException } from "../../src/exceptions/index.js"; +import { DiscordNotifier } from "../../src/index.js"; +import { IMessage } from "../../src/interfaces/index.js"; + +vi.mock("discord.js", () => { + const mockSend = vi.fn(); - class MockIntentsBitField { - static Flags = actualDiscord.IntentsBitField.Flags; - add() { - return this; - } - } + const MockWebhookClient = vi.fn(() => ({ + send: mockSend, + })); return { - ...actualDiscord, - Client: MockClient, - IntentsBitField: MockIntentsBitField, + WebhookClient: MockWebhookClient, }; }); describe("DiscordNotifier", () => { - const mockConfig = { - discordBotToken: "mock-token", - discordChannelId: "mock-channel-id", - }; - let notifier: DiscordNotifier; - const logger: ILogger = mocks.mockLogger(); + const webhookUrl = "https://discord.com/api/webhooks/TEST/TEST"; - beforeEach(async () => { + beforeEach(() => { vi.clearAllMocks(); - notifier = await DiscordNotifier.create(mockConfig, logger); + const mockLogger: ILogger = { + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }; + notifier = new DiscordNotifier(webhookUrl, mockLogger); }); - it("initializes the Discord client and login", async () => { - const ClientMock = Client as unknown as vi.Mock; - - expect(ClientMock).toHaveBeenCalledWith({ - intents: expect.any(IntentsBitField), + describe("send", () => { + it("initializes the WebhookClient with the correct URL", () => { + const WebhookClientMock = WebhookClient as vi.Mock; + expect(WebhookClientMock).toHaveBeenCalledWith({ url: webhookUrl }); }); - const instance = ClientMock.mock.results[0].value; - expect(instance.login).toHaveBeenCalledWith("mock-token"); - expect(instance.once).toHaveBeenCalledWith("ready", expect.any(Function)); + it("sends a message successfully", async () => { + const message: IMessage = { + title: "Test message", + subtitle: "Test subtitle", + description: "Test description", + username: "TestUserWithoutAvatar", + avatarUrl: "https://example.com/avatar.png", + actionUrl: "https://example.com/action", + }; + + await notifier.send(message); + + const WebhookClientMock = WebhookClient as vi.Mock; + const webhookClientInstance = WebhookClientMock.mock.results[0].value; + const sendMock = webhookClientInstance.send as vi.Mock; + + expect(sendMock).toHaveBeenCalledWith( + expect.objectContaining({ + username: "TestUserWithoutAvatar", + content: "Test message", + avatarURL: "https://example.com/avatar.png", + embeds: [ + { + title: "Test subtitle", + description: "Test description", + color: 2326507, + url: "https://example.com/action", + }, + ], + }), + ); + }); }); - it("sends an error message to the Discord channel", async () => { - const error = new Error("Test error"); - const context = { key: "value" }; - - await notifier.notifyError(error, context); - - const ClientMock = Client as unknown as vi.Mock; - const clientInstance = ClientMock.mock.results[0].value; - const fetchMock = clientInstance.channels.fetch as vi.Mock; + describe("sendOrThrow", () => { + it("sends a message successfully", async () => { + const message: IMessage = { + title: "Test message", + }; + + await notifier.sendOrThrow(message); + + const WebhookClientMock = WebhookClient as vi.Mock; + const webhookClientInstance = WebhookClientMock.mock.results[0].value; + const sendMock = webhookClientInstance.send as vi.Mock; + + expect(sendMock).toHaveBeenCalledWith( + expect.objectContaining({ + username: "EBO Agent", + content: "Test message", + avatarURL: undefined, + }), + ); + }); - expect(fetchMock).toHaveBeenCalledWith("mock-channel-id"); + it("throws NotificationFailureException when send fails", async () => { + const message: IMessage = { + title: "Test message", + }; - const channel = await fetchMock.mock.results[0].value; - expect(channel.isTextBased()).toBe(true); + const WebhookClientMock = WebhookClient as vi.Mock; + const webhookClientInstance = WebhookClientMock.mock.results[0].value; + const sendMock = webhookClientInstance.send as vi.Mock; + sendMock.mockRejectedValueOnce(new Error("Send failed")); - const sendMock = channel.send as vi.Mock; - expect(sendMock).toHaveBeenCalledWith(expect.stringContaining("**Error:**")); + await expect(notifier.sendOrThrow(message)).rejects.toThrow( + NotificationFailureException, + ); + }); }); - it("logs an error if the channel is not found", async () => { - const ClientMock = Client as unknown as vi.Mock; - const clientInstance = ClientMock.mock.results[0].value; - clientInstance.channels.fetch.mockResolvedValueOnce(null); + describe("createErrorMessage", () => { + it("creates an error message from an Error object", () => { + const error = new Error("Test error"); + const context = { foo: "bar" }; + const defaultMessage = "Default error message"; + + const errorMessage = notifier.createErrorMessage(defaultMessage, context, error); + + expect(errorMessage).toEqual({ + title: `**Error:** Error - Test error`, + description: `**Context:**\n\`\`\`json\n${stringify({ + foo: "bar", + stack: error.stack, + })}\n\`\`\``, + }); + }); - const error = new Error("Test error"); - const context = { key: "value" }; + it("creates an error message from a non-Error object", () => { + const error = { message: "String error" }; + const context = { foo: "bar" }; + const defaultMessage = "Default error message"; - await notifier.notifyError(error, context); + const errorMessage = notifier.createErrorMessage(defaultMessage, context, error); - expect(logger.error).toHaveBeenCalledWith( - "Failed to send error notification to Discord: Error: Discord channel not found or is not text-based", - ); + expect(errorMessage).toEqual({ + title: `**Error:** Default error message`, + description: `**Context:**\n\`\`\`json\n${stringify({ + foo: "bar", + error: stringify(error), + })}\n\`\`\``, + }); + }); }); - it("formats the error message correctly", () => { - const error = new Error("Test error message"); - error.name = "TestError"; - const context = { key: "value" }; + describe("sendError", () => { + it("sends an error message successfully", async () => { + const error = new Error("Test error"); + const context = { foo: "bar" }; + const defaultMessage = "Default error message"; + + const sendSpy = vi.spyOn(notifier, "send").mockResolvedValueOnce(); - const formattedMessage = (notifier as any).formatErrorMessage(error, context); + await notifier.sendError(defaultMessage, context, error); - expect(formattedMessage).toContain("**Error:** TestError - Test error message"); - expect(formattedMessage).toContain("**Context:**"); - expect(formattedMessage).toContain(JSON.stringify(context, null, 2)); + expect(sendSpy).toHaveBeenCalledWith({ + title: `**Error:** Error - Test error`, + description: `**Context:**\n\`\`\`json\n${stringify({ + foo: "bar", + stack: error.stack, + })}\n\`\`\``, + }); + + sendSpy.mockRestore(); + }); }); }); diff --git a/packages/automated-dispute/tests/services/eboProcessor.spec.ts b/packages/automated-dispute/tests/services/eboProcessor.spec.ts index a30282ef..74274dda 100644 --- a/packages/automated-dispute/tests/services/eboProcessor.spec.ts +++ b/packages/automated-dispute/tests/services/eboProcessor.spec.ts @@ -1,4 +1,4 @@ -import { UnixTimestamp } from "@ebo-agent/shared"; +import { Caip2ChainId, UnixTimestamp } from "@ebo-agent/shared"; import { Block, Hex } from "viem"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -38,7 +38,15 @@ describe("EboProcessor", () => { beforeEach(() => { vi.useFakeTimers(); notifier = { - notifyError: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockResolvedValue(undefined), + sendOrThrow: vi.fn().mockResolvedValue(undefined), + sendError: vi.fn().mockResolvedValue(undefined), + createErrorMessage: vi.fn((defaultMessage, context) => { + return { + title: defaultMessage, + description: JSON.stringify(context, null, 2), + }; + }), }; });