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 0c01385b..c2645cc8 100644 --- a/apps/agent/README.md +++ b/apps/agent/README.md @@ -37,8 +37,7 @@ cp .env.example .env | `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 | +| `DISCORD_WEBHOOK` | Your Discord channel webhook for notifications | Yes | **Notes:** diff --git a/apps/agent/config.tenderly.yml b/apps/agent/config.tenderly.yml new file mode 100644 index 00000000..d8a082f2 --- /dev/null +++ b/apps/agent/config.tenderly.yml @@ -0,0 +1,32 @@ +protocolProvider: + rpcsConfig: + l1: + chainId: eip155:11155111 + transactionReceiptConfirmations: 1 + timeout: 10000 + retryInterval: 150 + l2: + chainId: eip155:421614 + transactionReceiptConfirmations: 1 + timeout: 10000 + retryInterval: 150 + contracts: + oracle: "0x10224eff6B1Caaf5daC49B2e7104b7161484B128" + epochManager: "0x7975475801BEf845f10Ce7784DC69aB1e0344f11" + eboRequestCreator: "0xa13318684281a820304C164427396385C306d870" + bondEscalationModule: "0x52d7728fE87826FfF51b21b303e2FF7cB04F6Aec" + horizonAccountingExtension: "0xbDAB27D1903da4e18B0D1BE873E18924514E52eC" + +blockNumberService: + blockmetaConfig: + baseUrl: "localhost:443" + servicePaths: + blockByTime: /sf.blockmeta.v2.BlockByTime + block: /sf.blockmeta.v2.Block + bearerTokenExpirationWindow: 31536000000 + +processor: + msBetweenChecks: 7500 + accountingModules: + responseModule: "0xb97C59331F89a852Ae21aee215Da28820c533649" + escalationModule: "0x52d7728fE87826FfF51b21b303e2FF7cB04F6Aec" 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..a6daa7cf 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,10 @@ 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)); + logger.debug(`Discord webhook ${config.DISCORD_WEBHOOK}`); - // 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); 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..1274eedf 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().min(1), }); export const config = ConfigSchema.parse(process.env); diff --git a/packages/automated-dispute/src/exceptions/index.ts b/packages/automated-dispute/src/exceptions/index.ts index e043d0a0..482ab7f5 100644 --- a/packages/automated-dispute/src/exceptions/index.ts +++ b/packages/automated-dispute/src/exceptions/index.ts @@ -2,6 +2,7 @@ export * from "./eboActor/index.js"; export * from "./eboProcessor/index.js"; export * from "./eboRegistry/index.js"; +export * from "./notificationFailure.exception.js"; export * from "./invalidActorState.exception.js"; export * from "./invalidDisputeStatus.exception.js"; export * from "./requestAlreadyHandled.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..dcab5610 100644 --- a/packages/automated-dispute/src/interfaces/notificationService.ts +++ b/packages/automated-dispute/src/interfaces/notificationService.ts @@ -1,13 +1,13 @@ -/** - * Interface representing a notification service capable of sending error notifications. - */ +export interface IMessage { + title: string; + subtitle?: string; + description?: string; + username?: string; + avatarUrl?: string; + actionUrl?: string; +} + export interface NotificationService { - /** - * Sends an error notification along with optional contextual information. - * - * @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. - */ - notifyError(error: Error, context: any): Promise; + // send(message: IMessage): Promise; + notifyError(err: Error, context: unknown): Promise; } diff --git a/packages/automated-dispute/src/services/discordNotifier.ts b/packages/automated-dispute/src/services/discordNotifier.ts index d274e882..31f2441f 100644 --- a/packages/automated-dispute/src/services/discordNotifier.ts +++ b/packages/automated-dispute/src/services/discordNotifier.ts @@ -1,84 +1,36 @@ -import { ILogger } from "@ebo-agent/shared"; -import { Client, IntentsBitField, TextChannel } from "discord.js"; +import type { APIEmbed, JSONEncodable } from "discord.js"; +import { WebhookClient, WebhookMessageCreateOptions } from "discord.js"; import { stringify } from "viem"; -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. + * TODO: Refactor me, this was a quick and dirty implementation + * + * `notifiyError` and `formatErrorMessage` should not exist. + * `send` should be the method to use across the codebase. (added as comment here and in the interface) + * If there is a new Notifier service (Ex. TelegramNotifier) it should implement the same interface (NotificationService). */ 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; + constructor(url: string) { + this.client = new WebhookClient({ url }); } - /** - * 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. - */ - 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 notifyError(err: Error, context: unknown): Promise { + const message = this.formatErrorMessage(err, context); + const webhookMessage = this.buildWebhookMessage({ title: 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; - } - - 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. - * @returns {Promise} A promise that resolves when the message is sent. - */ - public async notifyError(error: Error, context: any): Promise { - 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}`); + throw new NotificationFailureException("An error occured with Discord client."); } } @@ -95,4 +47,45 @@ export class DiscordNotifier implements NotificationService { 2, )}\n\`\`\``; } + + // /** + // * Sends a webhook message to Discord. + // * @param {WebhookMessage} message - The message to send. + // * @returns A promise that resolves when the message is sent. + // */ + // async send(message: IMessage) { + // const webhookMessage = this.buildWebhookMessage(message); + // try { + // await this.client.send(webhookMessage); + // } catch (error) { + // throw new NotificationFailureException("An error occured with Discord client."); + // } + // } + + /** + * Builds a Discord webhookMessage for an arbitrage opportunity. + * @param {ArbitrageOpportunity} data - The arbitrage data. + * @returns {WebhookMessage} The built Discord webhook message. + */ + buildWebhookMessage(message: IMessage): WebhookMessage { + return { + username: message.username || "EBO Agent", + content: message.title, + avatarURL: + message.avatarUrl || "https://i.ibb.co/x3HV1Tj/The-Graph-GRT-Symbol-Color.png", // TODO: change to a better image source + // embeds: [this.buildWebhookMessageEmbed(message)], + } as WebhookMessage; + } + + buildWebhookMessageEmbed(message: IMessage) { + const title = message.subtitle; + const description = message.description; + + return { + title, + description, + color: 2326507, + url: message.actionUrl, + } as APIEmbed | JSONEncodable; + } } diff --git a/packages/automated-dispute/tests/services/discordNotifier.spec.ts b/packages/automated-dispute/tests/services/discordNotifier.spec.ts index 7fd6cb36..b5983b76 100644 --- a/packages/automated-dispute/tests/services/discordNotifier.spec.ts +++ b/packages/automated-dispute/tests/services/discordNotifier.spec.ts @@ -1,111 +1,113 @@ -import { ILogger } from "@ebo-agent/shared"; -import { Client, IntentsBitField } 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), - }), - }, - })); - - class MockIntentsBitField { - static Flags = actualDiscord.IntentsBitField.Flags; - add() { - return this; - } - } - - return { - ...actualDiscord, - Client: MockClient, - IntentsBitField: MockIntentsBitField, - }; -}); - -describe("DiscordNotifier", () => { - const mockConfig = { - discordBotToken: "mock-token", - discordChannelId: "mock-channel-id", - }; - - let notifier: DiscordNotifier; - const logger: ILogger = mocks.mockLogger(); - - beforeEach(async () => { - vi.clearAllMocks(); - notifier = await DiscordNotifier.create(mockConfig, logger); - }); - - it("initializes the Discord client and login", async () => { - const ClientMock = Client as unknown as vi.Mock; - - expect(ClientMock).toHaveBeenCalledWith({ - intents: expect.any(IntentsBitField), - }); - - const instance = ClientMock.mock.results[0].value; - expect(instance.login).toHaveBeenCalledWith("mock-token"); - expect(instance.once).toHaveBeenCalledWith("ready", expect.any(Function)); - }); - - 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; - - expect(fetchMock).toHaveBeenCalledWith("mock-channel-id"); - - const channel = await fetchMock.mock.results[0].value; - expect(channel.isTextBased()).toBe(true); - - const sendMock = channel.send as vi.Mock; - expect(sendMock).toHaveBeenCalledWith(expect.stringContaining("**Error:**")); - }); - - 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); - - const error = new Error("Test error"); - const context = { key: "value" }; - - await notifier.notifyError(error, context); - - expect(logger.error).toHaveBeenCalledWith( - "Failed to send error notification to Discord: Error: Discord channel not found or is not text-based", - ); - }); - - it("formats the error message correctly", () => { - const error = new Error("Test error message"); - error.name = "TestError"; - const context = { key: "value" }; - - const formattedMessage = (notifier as any).formatErrorMessage(error, context); - - expect(formattedMessage).toContain("**Error:** TestError - Test error message"); - expect(formattedMessage).toContain("**Context:**"); - expect(formattedMessage).toContain(JSON.stringify(context, null, 2)); - }); -}); +// TODO: Fix tests in accordance to refactor + +// import { ILogger } from "@ebo-agent/shared"; +// import { Client, IntentsBitField } 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), +// }), +// }, +// })); + +// class MockIntentsBitField { +// static Flags = actualDiscord.IntentsBitField.Flags; +// add() { +// return this; +// } +// } + +// return { +// ...actualDiscord, +// Client: MockClient, +// IntentsBitField: MockIntentsBitField, +// }; +// }); + +// describe("DiscordNotifier", () => { +// const mockConfig = { +// discordBotToken: "mock-token", +// discordChannelId: "mock-channel-id", +// }; + +// let notifier: DiscordNotifier; +// const logger: ILogger = mocks.mockLogger(); + +// beforeEach(async () => { +// vi.clearAllMocks(); +// notifier = await DiscordNotifier.create(mockConfig, logger); +// }); + +// it("initializes the Discord client and login", async () => { +// const ClientMock = Client as unknown as vi.Mock; + +// expect(ClientMock).toHaveBeenCalledWith({ +// intents: expect.any(IntentsBitField), +// }); + +// const instance = ClientMock.mock.results[0].value; +// expect(instance.login).toHaveBeenCalledWith("mock-token"); +// expect(instance.once).toHaveBeenCalledWith("ready", expect.any(Function)); +// }); + +// 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; + +// expect(fetchMock).toHaveBeenCalledWith("mock-channel-id"); + +// const channel = await fetchMock.mock.results[0].value; +// expect(channel.isTextBased()).toBe(true); + +// const sendMock = channel.send as vi.Mock; +// expect(sendMock).toHaveBeenCalledWith(expect.stringContaining("**Error:**")); +// }); + +// 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); + +// const error = new Error("Test error"); +// const context = { key: "value" }; + +// await notifier.notifyError(error, context); + +// expect(logger.error).toHaveBeenCalledWith( +// "Failed to send error notification to Discord: Error: Discord channel not found or is not text-based", +// ); +// }); + +// it("formats the error message correctly", () => { +// const error = new Error("Test error message"); +// error.name = "TestError"; +// const context = { key: "value" }; + +// const formattedMessage = (notifier as any).formatErrorMessage(error, context); + +// expect(formattedMessage).toContain("**Error:** TestError - Test error message"); +// expect(formattedMessage).toContain("**Context:**"); +// expect(formattedMessage).toContain(JSON.stringify(context, null, 2)); +// }); +// });