Adds support for broadcasting notifications to specified Slack channels.

Signed-off-by: Henrik Edegård <henrik.edegard@fortnox.se>
This commit is contained in:
Henrik Edegård
2025-11-26 12:03:26 +00:00
parent 9c64ee9776
commit f95a5167e9
6 changed files with 652 additions and 3 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-notifications-backend-module-slack': minor
---
Enables optional routes to Slack channels for broadcast notifications based on origin and/or topics.
+50
View File
@@ -154,6 +154,56 @@ notifications:
Multiple instances can be added in the `slack` array, allowing you to have multiple configurations if you need to send
messages to more than one Slack workspace. Org-Wide App installation is not currently supported.
### Broadcast Channel Routing
For more granular control over where broadcast notifications are sent, you can use `broadcastRoutes` to route notifications to different Slack channels based on their origin and/or topic. This is useful when you want different types of notifications to go to different channels.
```yaml
notifications:
processors:
slack:
- token: xoxb-XXXXXXXXX
# Legacy option - used as fallback when no routes match
broadcastChannels:
- general-notifications
# Route broadcasts based on origin and/or topic
broadcastRoutes:
# Most specific: matches both origin AND topic
- origin: plugin:catalog
topic: alerts
channel: catalog-alerts
# Origin only: all notifications from this origin
- origin: plugin:catalog
channel: catalog-updates
# Topic only: all notifications with this topic (any origin)
- topic: security
channel: security-team
# Multiple channels: send to several channels at once
- origin: external:monitoring
channel:
- ops-team
- on-call-alerts
```
#### Route Matching Precedence
Routes are evaluated in the following order of priority:
1. **Origin + Topic match** (most specific) - A route that specifies both `origin` and `topic` will match first
2. **Origin-only match** - A route with only `origin` specified (no `topic`)
3. **Topic-only match** - A route with only `topic` specified (no `origin`)
4. **Default fallback** - If no routes match, falls back to `broadcastChannels`
The first matching route wins. If no routes match and no `broadcastChannels` are configured, the broadcast notification will not be sent to Slack.
#### Configuration Options
| Property | Type | Description |
| --------- | ---------------------- | ------------------------------------------------------------------------------------------ |
| `origin` | `string` | Optional. The notification origin to match (e.g., `plugin:catalog`, `external:my-service`) |
| `topic` | `string` | Optional. The notification topic to match (e.g., `alerts`, `updates`, `security`) |
| `channel` | `string` or `string[]` | Required. The Slack channel(s) to send to. Can be channel IDs, channel names, or user IDs |
### Entity Requirements
Entities must be annotated with the following annotation:
+24
View File
@@ -26,8 +26,32 @@ export interface Config {
* Broadcast notification receivers when receiver is set to config
* These can be Slack User IDs, Slack User Email addresses, Slack Channel
* Names, or Slack Channel IDs. Any valid identifier that chat.postMessage can accept.
* @deprecated Use broadcastRoutes instead for more granular control
*/
broadcastChannels?: string[];
/**
* Optional username to display as the sender of the notification
*/
username?: string;
/**
* Routes for broadcast notifications based on origin and/or topic.
* Routes are evaluated in order, first match wins.
* Origin+topic matches take precedence over origin-only matches.
*/
broadcastRoutes?: Array<{
/**
* The origin to match (e.g., 'plugin:catalog', 'external:my-service')
*/
origin?: string;
/**
* The topic to match (e.g., 'entity-updated', 'alerts')
*/
topic?: string;
/**
* The Slack channel(s) to send to. Can be channel IDs, channel names, or user IDs.
*/
channel: string | string[];
}>;
}>;
};
};
@@ -393,6 +393,462 @@ describe('SlackNotificationProcessor', () => {
});
});
describe('when broadcast routes are configured', () => {
it('should route by origin and topic (highest priority)', async () => {
const slack = new WebClient();
const routesConfig = mockServices.rootConfig({
data: {
app: {
baseUrl: 'https://example.org',
},
notifications: {
processors: {
slack: [
{
token: 'mock-token',
broadcastChannels: ['default-channel'],
broadcastRoutes: [
{ origin: 'plugin:catalog', channel: 'catalog-channel' },
{ topic: 'alerts', channel: 'alerts-channel' },
{
origin: 'plugin:catalog',
topic: 'alerts',
channel: 'catalog-alerts-channel',
},
],
},
],
},
},
},
});
const processor = SlackNotificationProcessor.fromConfig(routesConfig, {
auth,
logger,
catalog: catalogServiceMock({
entities: DEFAULT_ENTITIES_RESPONSE.items,
}),
slack,
})[0];
await processor.postProcess(
{
origin: 'plugin:catalog',
id: '1234',
user: null,
created: new Date(),
payload: {
title: 'notification',
topic: 'alerts',
},
},
{
recipients: { type: 'broadcast' },
payload: { title: 'notification', topic: 'alerts' },
},
);
expect(slack.chat.postMessage).toHaveBeenCalledTimes(1);
expect(slack.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ channel: 'catalog-alerts-channel' }),
);
});
it('should route by origin only when no origin+topic match', async () => {
const slack = new WebClient();
const routesConfig = mockServices.rootConfig({
data: {
app: {
baseUrl: 'https://example.org',
},
notifications: {
processors: {
slack: [
{
token: 'mock-token',
broadcastRoutes: [
{ origin: 'plugin:catalog', channel: 'catalog-channel' },
{ topic: 'alerts', channel: 'alerts-channel' },
],
},
],
},
},
},
});
const processor = SlackNotificationProcessor.fromConfig(routesConfig, {
auth,
logger,
catalog: catalogServiceMock({
entities: DEFAULT_ENTITIES_RESPONSE.items,
}),
slack,
})[0];
await processor.postProcess(
{
origin: 'plugin:catalog',
id: '1234',
user: null,
created: new Date(),
payload: {
title: 'notification',
topic: 'updates',
},
},
{
recipients: { type: 'broadcast' },
payload: { title: 'notification', topic: 'updates' },
},
);
expect(slack.chat.postMessage).toHaveBeenCalledTimes(1);
expect(slack.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ channel: 'catalog-channel' }),
);
});
it('should route by topic only when no origin match', async () => {
const slack = new WebClient();
const routesConfig = mockServices.rootConfig({
data: {
app: {
baseUrl: 'https://example.org',
},
notifications: {
processors: {
slack: [
{
token: 'mock-token',
broadcastRoutes: [
{ origin: 'plugin:catalog', channel: 'catalog-channel' },
{ topic: 'alerts', channel: 'alerts-channel' },
],
},
],
},
},
},
});
const processor = SlackNotificationProcessor.fromConfig(routesConfig, {
auth,
logger,
catalog: catalogServiceMock({
entities: DEFAULT_ENTITIES_RESPONSE.items,
}),
slack,
})[0];
await processor.postProcess(
{
origin: 'plugin:unknown',
id: '1234',
user: null,
created: new Date(),
payload: {
title: 'notification',
topic: 'alerts',
},
},
{
recipients: { type: 'broadcast' },
payload: { title: 'notification', topic: 'alerts' },
},
);
expect(slack.chat.postMessage).toHaveBeenCalledTimes(1);
expect(slack.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ channel: 'alerts-channel' }),
);
});
it('should fall back to broadcastChannels when no route matches', async () => {
const slack = new WebClient();
const routesConfig = mockServices.rootConfig({
data: {
app: {
baseUrl: 'https://example.org',
},
notifications: {
processors: {
slack: [
{
token: 'mock-token',
broadcastChannels: ['default-channel'],
broadcastRoutes: [
{ origin: 'plugin:catalog', channel: 'catalog-channel' },
],
},
],
},
},
},
});
const processor = SlackNotificationProcessor.fromConfig(routesConfig, {
auth,
logger,
catalog: catalogServiceMock({
entities: DEFAULT_ENTITIES_RESPONSE.items,
}),
slack,
})[0];
await processor.postProcess(
{
origin: 'plugin:unknown',
id: '1234',
user: null,
created: new Date(),
payload: {
title: 'notification',
},
},
{
recipients: { type: 'broadcast' },
payload: { title: 'notification' },
},
);
expect(slack.chat.postMessage).toHaveBeenCalledTimes(1);
expect(slack.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ channel: 'default-channel' }),
);
});
it('should support multiple channels in a route', async () => {
const slack = new WebClient();
const routesConfig = mockServices.rootConfig({
data: {
app: {
baseUrl: 'https://example.org',
},
notifications: {
processors: {
slack: [
{
token: 'mock-token',
broadcastRoutes: [
{
origin: 'plugin:catalog',
channel: ['channel1', 'channel2', 'channel3'],
},
],
},
],
},
},
},
});
const processor = SlackNotificationProcessor.fromConfig(routesConfig, {
auth,
logger,
catalog: catalogServiceMock({
entities: DEFAULT_ENTITIES_RESPONSE.items,
}),
slack,
})[0];
await processor.postProcess(
{
origin: 'plugin:catalog',
id: '1234',
user: null,
created: new Date(),
payload: {
title: 'notification',
},
},
{
recipients: { type: 'broadcast' },
payload: { title: 'notification' },
},
);
expect(slack.chat.postMessage).toHaveBeenCalledTimes(3);
expect(slack.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ channel: 'channel1' }),
);
expect(slack.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ channel: 'channel2' }),
);
expect(slack.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ channel: 'channel3' }),
);
});
it('should support single channel as string in a route', async () => {
const slack = new WebClient();
const routesConfig = mockServices.rootConfig({
data: {
app: {
baseUrl: 'https://example.org',
},
notifications: {
processors: {
slack: [
{
token: 'mock-token',
broadcastRoutes: [
{ topic: 'alerts', channel: 'single-alerts-channel' },
],
},
],
},
},
},
});
const processor = SlackNotificationProcessor.fromConfig(routesConfig, {
auth,
logger,
catalog: catalogServiceMock({
entities: DEFAULT_ENTITIES_RESPONSE.items,
}),
slack,
})[0];
await processor.postProcess(
{
origin: 'plugin:any',
id: '1234',
user: null,
created: new Date(),
payload: {
title: 'notification',
topic: 'alerts',
},
},
{
recipients: { type: 'broadcast' },
payload: { title: 'notification', topic: 'alerts' },
},
);
expect(slack.chat.postMessage).toHaveBeenCalledTimes(1);
expect(slack.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ channel: 'single-alerts-channel' }),
);
});
it('should not send when no route matches and no broadcastChannels configured', async () => {
const slack = new WebClient();
const routesConfig = mockServices.rootConfig({
data: {
app: {
baseUrl: 'https://example.org',
},
notifications: {
processors: {
slack: [
{
token: 'mock-token',
broadcastRoutes: [
{ origin: 'plugin:catalog', channel: 'catalog-channel' },
],
},
],
},
},
},
});
const processor = SlackNotificationProcessor.fromConfig(routesConfig, {
auth,
logger,
catalog: catalogServiceMock({
entities: DEFAULT_ENTITIES_RESPONSE.items,
}),
slack,
})[0];
await processor.postProcess(
{
origin: 'plugin:unknown',
id: '1234',
user: null,
created: new Date(),
payload: {
title: 'notification',
topic: 'unknown-topic',
},
},
{
recipients: { type: 'broadcast' },
payload: { title: 'notification', topic: 'unknown-topic' },
},
);
expect(slack.chat.postMessage).not.toHaveBeenCalled();
});
it('should match origin+topic route over origin-only route', async () => {
const slack = new WebClient();
const routesConfig = mockServices.rootConfig({
data: {
app: {
baseUrl: 'https://example.org',
},
notifications: {
processors: {
slack: [
{
token: 'mock-token',
broadcastRoutes: [
// Origin-only route defined first
{ origin: 'plugin:catalog', channel: 'general-catalog' },
// More specific origin+topic route defined second
{
origin: 'plugin:catalog',
topic: 'entity-deleted',
channel: 'catalog-deletions',
},
],
},
],
},
},
},
});
const processor = SlackNotificationProcessor.fromConfig(routesConfig, {
auth,
logger,
catalog: catalogServiceMock({
entities: DEFAULT_ENTITIES_RESPONSE.items,
}),
slack,
})[0];
await processor.postProcess(
{
origin: 'plugin:catalog',
id: '1234',
user: null,
created: new Date(),
payload: {
title: 'notification',
topic: 'entity-deleted',
},
},
{
recipients: { type: 'broadcast' },
payload: { title: 'notification', topic: 'entity-deleted' },
},
);
// Should use the origin+topic match, not the origin-only match
expect(slack.chat.postMessage).toHaveBeenCalledTimes(1);
expect(slack.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ channel: 'catalog-deletions' }),
);
});
});
describe('when slack.com/bot-notify annotation is missing', () => {
it('should not send notification to a group without annotation', async () => {
const slack = new WebClient();
@@ -34,6 +34,7 @@ import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
import DataLoader from 'dataloader';
import pThrottle from 'p-throttle';
import { ANNOTATION_SLACK_BOT_NOTIFY } from './constants';
import { BroadcastRoute } from './types';
import { ExpiryMap, toChatPostMessageArgs } from './util';
import { CatalogService } from '@backstage/plugin-catalog-node';
@@ -48,6 +49,7 @@ export class SlackNotificationProcessor implements NotificationProcessor {
private readonly messagesSent: Counter;
private readonly messagesFailed: Counter;
private readonly broadcastChannels?: string[];
private readonly broadcastRoutes?: BroadcastRoute[];
private readonly entityLoader: DataLoader<string, Entity | undefined>;
private readonly username?: string;
@@ -68,9 +70,14 @@ export class SlackNotificationProcessor implements NotificationProcessor {
const slack = options.slack ?? new WebClient(token);
const broadcastChannels = c.getOptionalStringArray('broadcastChannels');
const username = c.getOptionalString('username');
const broadcastRoutesConfig = c.getOptionalConfigArray('broadcastRoutes');
const broadcastRoutes = broadcastRoutesConfig?.map(route =>
this.parseBroadcastRoute(route),
);
return new SlackNotificationProcessor({
slack,
broadcastChannels,
broadcastRoutes,
username,
...options,
});
@@ -83,15 +90,24 @@ export class SlackNotificationProcessor implements NotificationProcessor {
logger: LoggerService;
catalog: CatalogService;
broadcastChannels?: string[];
broadcastRoutes?: BroadcastRoute[];
username?: string;
}) {
const { auth, catalog, logger, slack, broadcastChannels, username } =
options;
const {
auth,
catalog,
logger,
slack,
broadcastChannels,
broadcastRoutes,
username,
} = options;
this.logger = logger;
this.catalog = catalog;
this.auth = auth;
this.slack = slack;
this.broadcastChannels = broadcastChannels;
this.broadcastRoutes = broadcastRoutes;
this.username = username;
this.entityLoader = new DataLoader<string, Entity | undefined>(
@@ -235,7 +251,8 @@ export class SlackNotificationProcessor implements NotificationProcessor {
// Handle broadcast case
if (notification.user === null) {
destinations.push(...(this.broadcastChannels ?? []));
const routedChannels = this.getBroadcastDestinations(notification);
destinations.push(...routedChannels);
} else if (options.recipients.type === 'entity') {
// Handle user-specific notification
const entityRefs = [options.recipients.entityRef].flat();
@@ -384,4 +401,88 @@ export class SlackNotificationProcessor implements NotificationProcessor {
throw new Error(`Failed to send notification: ${response.error}`);
}
}
private static parseBroadcastRoute(route: Config): BroadcastRoute {
const channelValue = route.getOptional('channel');
let channels: string[];
if (typeof channelValue === 'string') {
channels = [channelValue];
} else if (Array.isArray(channelValue)) {
channels = channelValue as string[];
} else {
throw new Error(
'broadcastRoutes entry must have a channel property (string or string[])',
);
}
return {
origin: route.getOptionalString('origin'),
topic: route.getOptionalString('topic'),
channels,
};
}
/**
* Gets the destination channels for a broadcast notification based on
* configured routes. Routes are matched by origin and/or topic.
*
* Matching precedence:
* 1. Routes with both origin AND topic matching (most specific)
* 2. Routes with only origin matching
* 3. Routes with only topic matching
* 4. Default broadcastChannels (least specific fallback)
*
* The first matching route wins within each precedence level.
*/
private getBroadcastDestinations(notification: Notification): string[] {
const { origin } = notification;
const { topic } = notification.payload;
if (!this.broadcastRoutes || this.broadcastRoutes.length === 0) {
// Fall back to legacy broadcastChannels config
return this.broadcastChannels ?? [];
}
// Find most specific match
// Priority 1: origin AND topic match
const originAndTopicMatch = this.broadcastRoutes.find(
route =>
route.origin !== undefined &&
route.topic !== undefined &&
route.origin === origin &&
route.topic === topic,
);
if (originAndTopicMatch) {
return originAndTopicMatch.channels;
}
// Priority 2: origin-only match (no topic specified in route)
const originOnlyMatch = this.broadcastRoutes.find(
route =>
route.origin !== undefined &&
route.topic === undefined &&
route.origin === origin,
);
if (originOnlyMatch) {
return originOnlyMatch.channels;
}
// Priority 3: topic-only match (no origin specified in route)
const topicOnlyMatch = this.broadcastRoutes.find(
route =>
route.topic !== undefined &&
route.origin === undefined &&
route.topic === topic,
);
if (topicOnlyMatch) {
return topicOnlyMatch.channels;
}
// No match found, fall back to legacy broadcastChannels
return this.broadcastChannels ?? [];
}
}
@@ -18,3 +18,16 @@ export interface SlackNotificationOptions {
url: string;
payload: string;
}
/**
* Configuration for routing broadcast notifications to specific Slack channels
* based on origin and/or topic.
*/
export type BroadcastRoute = {
/** The origin to match (e.g., 'plugin:catalog', 'external:my-service') */
origin?: string;
/** The topic to match (e.g., 'entity-updated', 'alerts') */
topic?: string;
/** The Slack channel(s) to send to */
channels: string[];
};