feat(notifications): add Rocket.Chat provider

Refs #3821
This commit is contained in:
Marcos Paulo Diniz committed 2026-08-02 01:59:11 -03:00
1 parent 3646af9c04
commit 750fc9e238
8 files changed
+306

No files matched your search

@@ -23,6 +23,14 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
roomId: z.union([z.string(), z.literal("")]).optional(),
accessToken: z.union([z.string(), z.literal("")]).optional(),
}),
// Rocket.Chat notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
type: z.literal("rocket_chat"),
address: z
.url({ message: "Please enter a valid Rocket.Chat webhook URL" })
.refine((url) => /^https?:\/\//i.test(url), { message: "Rocket.Chat webhook URL must use HTTP or HTTPS" }),
}),
// Slack notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
+3
View File
@@ -23,6 +23,7 @@ import { ILogger } from "@/utils/logger.js";
// Notification providers
import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js";
import { WebhookProvider } from "@/domain/notifications/providers/webhook.js";
import { RocketChatProvider } from "@/domain/notifications/providers/rocketChat.js";
import { SlackProvider } from "@/domain/notifications/providers/slack.js";
import { EmailProvider } from "@/domain/notifications/providers/email.js";
import { DiscordProvider } from "@/domain/notifications/providers/discord.js";
@@ -157,6 +158,7 @@ export const buildShared = async ({
});
const webhookProvider = new WebhookProvider(logger);
const rocketChatProvider = new RocketChatProvider(logger);
const slackProvider = new SlackProvider(logger);
const emailProvider = new EmailProvider(emailService, logger);
const discordProvider = new DiscordProvider(logger);
@@ -170,6 +172,7 @@ export const buildShared = async ({
const notificationProviders: NotificationProviderRegistry = {
webhook: webhookProvider,
rocket_chat: rocketChatProvider,
email: emailProvider,
slack: slackProvider,
discord: discordProvider,
@@ -30,6 +30,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
"slack",
"discord",
"webhook",
"rocket_chat",
"pager_duty",
"matrix",
"teams",
@@ -3,6 +3,7 @@ export const NotificationChannels = [
"slack",
"discord",
"webhook",
"rocket_chat",
"pager_duty",
"matrix",
"teams",
@@ -0,0 +1,88 @@
const SERVICE_NAME = "RocketChatProvider";
import type { Notification, NotificationMessage } from "@/domain/notifications/notification.type.js";
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
import got from "got";
export class RocketChatProvider extends NotificationProvider {
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.address) {
return false;
}
try {
await got.post(notification.address, {
json: { text: getTestMessage() },
headers: {
"Content-Type": "application/json",
},
...this.gotRequestOptions(),
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "Rocket.Chat test alert failed",
service: SERVICE_NAME,
method: "sendTestAlert",
stack: err?.stack,
});
return false;
}
}
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address) {
return false;
}
try {
await got.post(notification.address, {
json: { text: this.buildText(message) },
headers: {
"Content-Type": "application/json",
},
...this.gotRequestOptions(),
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "Rocket.Chat notification failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: err?.stack,
});
return false;
}
}
private buildText(message: NotificationMessage): string {
const lines = [
message.content.title,
message.content.summary,
"",
`Monitor: ${message.monitor.name}`,
`Type: ${message.monitor.type}`,
`Status: ${message.monitor.status}`,
`URL: ${message.monitor.url}`,
];
if (message.content.details?.length) {
lines.push("", "Details:", ...message.content.details.map((detail) => `- ${detail}`));
}
if (message.content.thresholds?.length) {
lines.push(
"",
"Thresholds:",
...message.content.thresholds.map(
(threshold) => `- ${threshold.metric.toUpperCase()}: ${threshold.formattedValue} (threshold: ${threshold.threshold}${threshold.unit})`
)
);
}
lines.push("", `Incident: ${message.clientHost}/infrastructure/${message.monitor.id}`);
return lines.join("\n");
}
}
@@ -0,0 +1,15 @@
import { describe, expect, it } from "@jest/globals";
import { NotificationModel } from "@/domain/notifications/notification.model.js";
import { NotificationChannels } from "@/domain/notifications/notification.type.js";
describe("NotificationChannels", () => {
it("includes Rocket.Chat", () => {
expect(NotificationChannels).toContain("rocket_chat");
});
it("allows Rocket.Chat to be persisted", () => {
const typePath = NotificationModel.schema.path("type") as unknown as { enumValues: string[] };
expect(typePath.enumValues).toContain("rocket_chat");
});
});
@@ -0,0 +1,143 @@
import { describe, expect, it, jest } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeMessage, makeMessageWithThresholds, makeNotification } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
const { RocketChatProvider } = await import("../../../../src/domain/notifications/providers/rocketChat.ts");
const createProvider = () => {
const logger = createMockLogger();
const provider = new RocketChatProvider(logger as any);
return { provider, logger };
};
testNotificationProviderContract("RocketChatProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification({ type: "rocket_chat" }),
});
describe("RocketChatProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
it("sends a text-only test alert", async () => {
const { provider } = createProvider();
const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat" }));
expect(result).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
"https://hooks.example.com/webhook",
expect.objectContaining({
json: { text: "This is a test notification from Checkmate" },
timeout: { request: 30000 },
retry: { limit: 0 },
})
);
});
it("returns false when the test webhook URL is missing", async () => {
const { provider } = createProvider();
const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat", address: "" }));
expect(result).toBe(false);
expect(mockGotPost).not.toHaveBeenCalled();
});
it("returns false and logs when test delivery fails", async () => {
mockGotPost.mockRejectedValue(new Error("network failure"));
const { provider, logger } = createProvider();
const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat" }));
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: "Rocket.Chat test alert failed",
service: "RocketChatProvider",
method: "sendTestAlert",
})
);
});
it("sends monitor details as a text-only message", async () => {
const { provider } = createProvider();
const result = await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage());
expect(result).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
"https://hooks.example.com/webhook",
expect.objectContaining({
timeout: { request: 30000 },
retry: { limit: 0 },
json: {
text: [
"Monitor Down: Test Monitor",
'Monitor "Test Monitor" is currently down.',
"",
"Monitor: Test Monitor",
"Type: http",
"Status: down",
"URL: https://example.com",
"",
"Details:",
"- URL: https://example.com",
"- Status: Down",
"",
"Incident: https://app.example.com/infrastructure/mon-1",
].join("\n"),
},
})
);
});
it("returns false when the message webhook URL is missing", async () => {
const { provider } = createProvider();
const result = await provider.sendMessage(makeNotification({ type: "rocket_chat", address: "" }), makeMessage());
expect(result).toBe(false);
expect(mockGotPost).not.toHaveBeenCalled();
});
it("includes threshold breaches in the text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessageWithThresholds());
const payload = mockGotPost.mock.calls[0][1].json;
expect(payload.text).toContain("Thresholds:\n- CPU: 90.0% (threshold: 80%)\n- MEMORY: 85.0% (threshold: 70%)");
});
it("includes a monitor-scoped incident link without synthetic incident data", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage());
const payload = mockGotPost.mock.calls[0][1].json;
expect(payload.text).toContain("Incident: https://app.example.com/infrastructure/mon-1");
});
it("returns false and logs when message delivery fails", async () => {
mockGotPost.mockRejectedValue(new Error("network failure"));
const { provider, logger } = createProvider();
const result = await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: "Rocket.Chat notification failed",
service: "RocketChatProvider",
method: "sendMessage",
})
);
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "@jest/globals";
import { createNotificationBodyValidation } from "@/api/validation/notificationValidation.js";
describe("notification validation", () => {
it("accepts a Rocket.Chat incoming webhook", () => {
const result = createNotificationBodyValidation.safeParse({
notificationName: "Rocket.Chat alerts",
type: "rocket_chat",
address: "https://chat.example.com/hooks/integration-id/token",
});
expect(result.success).toBe(true);
});
it("accepts a self-hosted Rocket.Chat webhook over HTTP", () => {
const result = createNotificationBodyValidation.safeParse({
notificationName: "Local Rocket.Chat alerts",
type: "rocket_chat",
address: "http://localhost:3000/hooks/integration-id/token",
});
expect(result.success).toBe(true);
});
it("rejects an invalid Rocket.Chat incoming webhook URL", () => {
const result = createNotificationBodyValidation.safeParse({
notificationName: "Rocket.Chat alerts",
type: "rocket_chat",
address: "not-a-url",
});
expect(result.success).toBe(false);
});
it.each(["ftp://chat.example.com/hooks/integration-id/token", "mailto:admin@example.com", "data:text/plain,hello", "javascript:alert(1)"])(
"rejects a Rocket.Chat webhook using an unsupported protocol: %s",
(address) => {
const result = createNotificationBodyValidation.safeParse({
notificationName: "Rocket.Chat alerts",
type: "rocket_chat",
address,
});
expect(result.success).toBe(false);
}
);
});