Skip to content

Commit 61951e1

Browse files
authored
Merge pull request #28 from joaopcm/feat/discord-alert-channel
feat(alerts): add Discord as an official alert channel
2 parents 2366154 + 0f216ee commit 61951e1

7 files changed

Lines changed: 254 additions & 13 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -395,11 +395,11 @@ Visit `http://localhost:PORT/jobs`.
395395
| `basePath` | `string` | Override base path detection. Required for `@getworkbench/elysia`, `@getworkbench/koa`, `@getworkbench/next`, `@getworkbench/tanstack-start`, `@getworkbench/astro`, `@getworkbench/nuxt`, and `@getworkbench/h3`. |
396396
| `readonly` | `boolean` | Disable actions (retry, remove, promote). |
397397
| `tags` | `string[]` | Fields from `job.data` to extract as filterable tags. |
398-
| `alerts` | `AlertsOptions` | Self-hosted Slack/webhook alerting via BullMQ QueueEvents. |
398+
| `alerts` | `AlertsOptions` | Self-hosted Slack/Discord/webhook alerting via BullMQ QueueEvents. |
399399

400400
### Alerts
401401

402-
Alerts are on by default. Configure Slack or webhook **contact points** and **rules** in the dashboard **Alerts** page — notifications are only sent after you set that up. The model follows Grafana-style contact points + rules (unlike Bull Board, which has no built-in alerting). See [`@getworkbench/core` README](./packages/core/README.md) for Slack setup steps.
402+
Alerts are on by default. Configure Slack, Discord, or webhook **contact points** and **rules** in the dashboard **Alerts** page — notifications are only sent after you set that up. The model follows Grafana-style contact points + rules (unlike Bull Board, which has no built-in alerting). See [`@getworkbench/core` README](./packages/core/README.md) for setup steps.
403403

404404
## Packages
405405

apps/web/content/docs/guides/alerts.mdx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
---
22
title: Alerts
3-
description: Self-hosted Slack and webhook alerting via BullMQ QueueEvents — contact points and rules in the dashboard.
3+
description: Self-hosted Slack, Discord, and webhook alerting via BullMQ QueueEvents — contact points and rules in the dashboard.
44
keywords:
55
- Workbench alerts
66
- BullMQ Slack alerts
7+
- BullMQ Discord alerts
78
---
89

910
Alerts are **enabled by default**. Configure contact points and rules from the dashboard **Alerts** page — nothing is sent until you add destinations and matching rules.
1011

1112
## Configure in the dashboard
1213

1314
1. Open your Workbench mount → **Alerts**
14-
2. **Add contact point** — Slack webhook or generic HTTP webhook
15+
2. **Add contact point** — Slack webhook, Discord webhook, or generic HTTP webhook
1516
3. **Add rule** — pick a trigger (job failed, stalled, retries exhausted, etc.) and link contact points
1617

1718
## Optional seed in code
@@ -73,6 +74,12 @@ Dashboard-managed config is **persisted in Redis** at `{prefix}:workbench:alerts
7374

7475
Set `alerts.dashboardUrl` so notifications include an **Open in Workbench** link.
7576

77+
## Discord setup
78+
79+
1. Open your server → **Server Settings****Integrations****Webhooks**
80+
2. Click **New Webhook**, pick a channel, then **Copy Webhook URL** (`https://discord.com/api/webhooks/...`)
81+
3. In Workbench **Alerts****Add contact point** → preset **Discord** → paste URL → **Test**
82+
7683
## Comparison
7784

7885
| Tool | Alert setup |

packages/core/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ Shared by every adapter and by `createFetchHandler` / `WorkbenchCore`:
118118
| `basePath` | `string` | Override base path detection. Required for some adapters (Elysia, Koa, Next.js, TanStack Start, Astro, Nuxt, h3, Adonis). |
119119
| `readonly` | `boolean` | Disable actions (retry, remove, promote). |
120120
| `tags` | `string[]` | Fields from `job.data` to extract as filterable tags. |
121-
| `alerts` | `AlertsOptions` | Self-hosted alerting via BullMQ `QueueEvents`, Slack/webhook contact points, and dashboard-managed rules. |
121+
| `alerts` | `AlertsOptions` | Self-hosted alerting via BullMQ `QueueEvents`, Slack/Discord/webhook contact points, and dashboard-managed rules. |
122122

123123
## Alerts
124124

packages/core/src/core/alert-destinations.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { describe, expect, test } from "bun:test";
22
import {
3+
formatDiscordPayload,
34
formatSlackPayload,
5+
isDiscordWebhookUrl,
46
isSlackIncomingWebhookUrl,
57
validateContactPointUrl,
68
} from "./alert-destinations";
@@ -16,6 +18,16 @@ const slackCp: AlertContactPoint = {
1618
updatedAt: 0,
1719
};
1820

21+
const discordCp: AlertContactPoint = {
22+
id: "cp-2",
23+
name: "Discord #ops",
24+
preset: "discord",
25+
url: "https://discord.com/api/webhooks/123/abcDEF",
26+
enabled: true,
27+
createdAt: 0,
28+
updatedAt: 0,
29+
};
30+
1931
const sampleEvent: AlertEvent = {
2032
id: "evt-1",
2133
ruleId: "rule-1",
@@ -56,13 +68,48 @@ describe("isSlackIncomingWebhookUrl", () => {
5668
});
5769
});
5870

71+
describe("isDiscordWebhookUrl", () => {
72+
test("accepts standard Discord webhook URLs", () => {
73+
expect(
74+
isDiscordWebhookUrl("https://discord.com/api/webhooks/123/abcDEF"),
75+
).toBe(true);
76+
});
77+
78+
test("accepts PTB Discord webhook URLs", () => {
79+
expect(
80+
isDiscordWebhookUrl("https://ptb.discord.com/api/webhooks/123/abcDEF"),
81+
).toBe(true);
82+
});
83+
84+
test("rejects non-Discord URLs", () => {
85+
expect(isDiscordWebhookUrl("https://example.com/api/webhooks/1/x")).toBe(
86+
false,
87+
);
88+
});
89+
});
90+
5991
describe("validateContactPointUrl", () => {
6092
test("requires Slack webhook host for slack preset", () => {
6193
expect(
6294
validateContactPointUrl("slack", "https://example.com/hook"),
6395
).toContain("incoming webhook");
6496
});
6597

98+
test("requires Discord webhook host for discord preset", () => {
99+
expect(
100+
validateContactPointUrl("discord", "https://example.com/hook"),
101+
).toContain("discord.com");
102+
});
103+
104+
test("accepts a valid Discord webhook URL", () => {
105+
expect(
106+
validateContactPointUrl(
107+
"discord",
108+
"https://discord.com/api/webhooks/123/abcDEF",
109+
),
110+
).toBeUndefined();
111+
});
112+
66113
test("allows generic https URLs for webhook preset", () => {
67114
expect(
68115
validateContactPointUrl("webhook", "https://example.com/hook"),
@@ -110,3 +157,39 @@ describe("formatSlackPayload", () => {
110157
expect(context?.elements).toBeDefined();
111158
});
112159
});
160+
161+
describe("formatDiscordPayload", () => {
162+
test("includes a colored embed and content fallback", () => {
163+
const { url, headers, body } = formatDiscordPayload(discordCp, sampleEvent);
164+
165+
expect(url).toBe(discordCp.url);
166+
expect(headers["Content-Type"]).toBe("application/json");
167+
168+
const payload = body as {
169+
content: string;
170+
embeds: Array<{ color: number; title: string }>;
171+
};
172+
expect(payload.content).toContain("Job failed");
173+
expect(payload.embeds).toHaveLength(1);
174+
expect(payload.embeds[0]?.color).toBe(0xf59e0b);
175+
expect(payload.embeds[0]?.title).toContain("Job failed");
176+
});
177+
178+
test("honors a custom sender name", () => {
179+
const { body } = formatDiscordPayload(
180+
{ ...discordCp, displayName: "Ops Bot" },
181+
sampleEvent,
182+
);
183+
expect((body as { username: string }).username).toBe("Ops Bot");
184+
});
185+
186+
test("links to the dashboard for https URLs", () => {
187+
const { body } = formatDiscordPayload(
188+
discordCp,
189+
sampleEvent,
190+
"https://jobs.example.com/jobs",
191+
);
192+
const payload = body as { embeds: Array<{ description: string }> };
193+
expect(payload.embeds[0]?.description).toContain("Open in Workbench");
194+
});
195+
});

packages/core/src/core/alert-destinations.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,27 @@ export function isSlackIncomingWebhookUrl(url: string): boolean {
2222
}
2323
}
2424

25+
const DISCORD_WEBHOOK_HOSTS = new Set([
26+
"discord.com",
27+
"discordapp.com",
28+
"canary.discord.com",
29+
"ptb.discord.com",
30+
]);
31+
32+
/** Discord channel webhook URLs (https://discord.com/api/webhooks/:id/:token). */
33+
export function isDiscordWebhookUrl(url: string): boolean {
34+
try {
35+
const parsed = new URL(url);
36+
return (
37+
parsed.protocol === "https:" &&
38+
DISCORD_WEBHOOK_HOSTS.has(parsed.hostname) &&
39+
parsed.pathname.startsWith("/api/webhooks/")
40+
);
41+
} catch {
42+
return false;
43+
}
44+
}
45+
2546
export function validateContactPointUrl(
2647
preset: AlertContactPoint["preset"],
2748
url: string,
@@ -39,6 +60,10 @@ export function validateContactPointUrl(
3960
return "Slack contact points require an incoming webhook URL (https://hooks.slack.com/services/...)";
4061
}
4162

63+
if (preset === "discord" && !isDiscordWebhookUrl(url)) {
64+
return "Discord contact points require a webhook URL (https://discord.com/api/webhooks/...)";
65+
}
66+
4267
return undefined;
4368
}
4469

@@ -50,6 +75,16 @@ const SEVERITY_EMOJI: Record<string, string> = {
5075
info: "🔵",
5176
};
5277

78+
const DISCORD_NEUTRAL_COLOR = 0x6b7280;
79+
80+
const SEVERITY_COLOR: Record<string, number> = {
81+
critical: 0xef4444,
82+
warning: 0xf59e0b,
83+
info: 0x3b82f6,
84+
};
85+
86+
const DISCORD_FIELD_MAX = 1024;
87+
5388
function severityLabel(severity: string): string {
5489
return severity.charAt(0).toUpperCase() + severity.slice(1);
5590
}
@@ -226,6 +261,79 @@ export function formatWebhookPayload(
226261
};
227262
}
228263

264+
export function formatDiscordPayload(
265+
contactPoint: AlertContactPoint,
266+
event: AlertEvent,
267+
dashboardUrl?: string,
268+
): FormattedDestinationPayload {
269+
const emoji = SEVERITY_EMOJI[event.severity] ?? "⚪";
270+
const link = buildDashboardLink(dashboardUrl, event);
271+
const senderName = contactPoint.displayName ?? DEFAULT_ALERT_SENDER_NAME;
272+
const iconUrl = contactPoint.iconUrl ?? DEFAULT_WORKBENCH_ICON_URL;
273+
274+
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
275+
const pushField = (name: string, value: string, inline = true) => {
276+
const trimmed = value.trim();
277+
if (trimmed) {
278+
fields.push({ name, value: trimmed.slice(0, DISCORD_FIELD_MAX), inline });
279+
}
280+
};
281+
282+
if (event.queue) {
283+
pushField("Queue", event.queue);
284+
}
285+
if (event.jobName || event.jobId) {
286+
pushField("Job", `${event.jobName ?? "—"} (\`${event.jobId ?? "—"}\`)`);
287+
}
288+
if (event.failedReason) {
289+
pushField("Reason", event.failedReason.slice(0, 500), false);
290+
}
291+
if (event.counts) {
292+
const parts: string[] = [];
293+
if (event.counts.failed !== undefined) {
294+
parts.push(`Failed: ${event.counts.failed}`);
295+
}
296+
if (event.counts.backlog !== undefined) {
297+
parts.push(`Backlog: ${event.counts.backlog}`);
298+
}
299+
if (event.counts.workers !== undefined && event.counts.workers !== null) {
300+
parts.push(`Workers: ${event.counts.workers}`);
301+
}
302+
pushField("Counts", parts.join(" · "));
303+
}
304+
305+
let description = `**${severityLabel(event.severity)}** · \`${event.trigger}\`\n${event.message}`;
306+
if (link?.startsWith("https://")) {
307+
description += `\n\n**[Open in Workbench](${link})**`;
308+
} else if (link) {
309+
description += `\n\n${link}`;
310+
}
311+
312+
const body = {
313+
username: senderName,
314+
avatar_url: iconUrl,
315+
content: `${emoji} ${event.ruleName}: ${event.message}`,
316+
embeds: [
317+
{
318+
title: `${emoji} ${event.ruleName}`,
319+
description,
320+
color: SEVERITY_COLOR[event.severity] ?? DISCORD_NEUTRAL_COLOR,
321+
fields,
322+
footer: {
323+
text: event.status === "resolved" ? "✅ Resolved" : "🔔 Firing",
324+
},
325+
timestamp: new Date(event.firedAt).toISOString(),
326+
},
327+
],
328+
};
329+
330+
return {
331+
url: contactPoint.url,
332+
headers: { "Content-Type": "application/json" },
333+
body,
334+
};
335+
}
336+
229337
export function formatDestinationPayload(
230338
contactPoint: AlertContactPoint,
231339
event: AlertEvent,
@@ -234,6 +342,9 @@ export function formatDestinationPayload(
234342
if (contactPoint.preset === "slack") {
235343
return formatSlackPayload(contactPoint, event, dashboardUrl);
236344
}
345+
if (contactPoint.preset === "discord") {
346+
return formatDiscordPayload(contactPoint, event, dashboardUrl);
347+
}
237348
return formatWebhookPayload(contactPoint, event, dashboardUrl);
238349
}
239350

packages/core/src/core/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,9 @@ export type AlertTrigger =
6666

6767
export type AlertSeverity = "critical" | "warning" | "info";
6868

69-
export type AlertContactPointPreset = "slack" | "webhook";
69+
export type AlertContactPointPreset = "slack" | "webhook" | "discord";
7070

71-
/** Where notifications are sent (Slack incoming webhook or generic webhook) */
71+
/** Where notifications are sent (Slack/Discord incoming webhook or generic webhook) */
7272
export interface AlertContactPoint {
7373
id: string;
7474
name: string;

0 commit comments

Comments
 (0)