diff --git a/.changeset/forty-papers-enjoy.md b/.changeset/forty-papers-enjoy.md new file mode 100644 index 0000000000..d92c390e6c --- /dev/null +++ b/.changeset/forty-papers-enjoy.md @@ -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. diff --git a/docs/notifications/processors.md b/docs/notifications/processors.md index c25b669ed6..0802fde6cf 100644 --- a/docs/notifications/processors.md +++ b/docs/notifications/processors.md @@ -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: diff --git a/plugins/notifications-backend-module-slack/config.d.ts b/plugins/notifications-backend-module-slack/config.d.ts index 8cd450940e..66700e681f 100644 --- a/plugins/notifications-backend-module-slack/config.d.ts +++ b/plugins/notifications-backend-module-slack/config.d.ts @@ -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[]; + }>; }>; }; }; diff --git a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts index ad4bf01e9f..34e78d22d3 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts @@ -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(); diff --git a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts index f9b00c9a58..058a964e15 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts @@ -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; 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( @@ -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 ?? []; + } } diff --git a/plugins/notifications-backend-module-slack/src/lib/types.ts b/plugins/notifications-backend-module-slack/src/lib/types.ts index bd87e9656c..316df03462 100644 --- a/plugins/notifications-backend-module-slack/src/lib/types.ts +++ b/plugins/notifications-backend-module-slack/src/lib/types.ts @@ -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[]; +};