diff --git a/.changeset/fancy-ends-turn.md b/.changeset/fancy-ends-turn.md new file mode 100644 index 0000000000..7787b47971 --- /dev/null +++ b/.changeset/fancy-ends-turn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-notifications-backend-module-slack': minor +--- + +Add an extension for custom Slack message layouts diff --git a/docs/notifications/processors.md b/docs/notifications/processors.md index af0b9f8f93..501678d107 100644 --- a/docs/notifications/processors.md +++ b/docs/notifications/processors.md @@ -156,6 +156,34 @@ 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. +### Customize Slack Message Structure + +You can customize how notifications look in Slack by providing your own message layout through the `notificationsSlackBlockKitExtensionPoint` + +```ts +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { notificationsSlackBlockKitExtensionPoint } from '@backstage/plugin-notifications-backend-module-slack'; + +export const notificationsSlackFormattingModule = createBackendModule({ + pluginId: 'notifications', + moduleId: 'slack-formatting', + register(reg) { + reg.registerInit({ + deps: { + slackBlockKit: notificationsSlackBlockKitExtensionPoint, + }, + async init({ slackBlockKit }) { + slackBlockKit.setBlockKitRenderer(payload => [ + // Custom block kit layout + ]); + }, + }); + }, +}); +``` + +If you do not register a custom renderer, the default renderer is used. + ### 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. diff --git a/plugins/notifications-backend-module-slack/report.api.md b/plugins/notifications-backend-module-slack/report.api.md index 3c4ddb494d..6fb477c1ee 100644 --- a/plugins/notifications-backend-module-slack/report.api.md +++ b/plugins/notifications-backend-module-slack/report.api.md @@ -4,6 +4,9 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { ExtensionPoint } from '@backstage/backend-plugin-api'; +import { KnownBlock } from '@slack/web-api'; +import { NotificationPayload } from '@backstage/plugin-notifications-common'; // @public export const ANNOTATION_SLACK_BOT_NOTIFY = 'slack.com/bot-notify'; @@ -11,4 +14,18 @@ export const ANNOTATION_SLACK_BOT_NOTIFY = 'slack.com/bot-notify'; // @public const notificationsModuleSlack: BackendFeature; export default notificationsModuleSlack; + +// @public +export interface NotificationsSlackBlockKitExtensionPoint { + // (undocumented) + setBlockKitRenderer(renderer: SlackBlockKitRenderer): void; +} + +// @public (undocumented) +export const notificationsSlackBlockKitExtensionPoint: ExtensionPoint; + +// @public (undocumented) +export type SlackBlockKitRenderer = ( + payload: NotificationPayload, +) => KnownBlock[]; ``` diff --git a/plugins/notifications-backend-module-slack/src/extensions.ts b/plugins/notifications-backend-module-slack/src/extensions.ts new file mode 100644 index 0000000000..c2465b69cf --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/extensions.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createExtensionPoint } from '@backstage/backend-plugin-api'; +import { NotificationPayload } from '@backstage/plugin-notifications-common'; +import { KnownBlock } from '@slack/web-api'; + +/** + * @public + */ +export type SlackBlockKitRenderer = ( + payload: NotificationPayload, +) => KnownBlock[]; + +/** + * @public + * + * Extension point for customizing how notification payloads are rendered into + * Slack Block Kit messages before they're sent. + */ +export interface NotificationsSlackBlockKitExtensionPoint { + setBlockKitRenderer(renderer: SlackBlockKitRenderer): void; +} + +/** + * @public + */ +export const notificationsSlackBlockKitExtensionPoint = + createExtensionPoint({ + id: 'notifications.slack.blockkit', + }); diff --git a/plugins/notifications-backend-module-slack/src/index.ts b/plugins/notifications-backend-module-slack/src/index.ts index f8a9e248f7..178c5dbc54 100644 --- a/plugins/notifications-backend-module-slack/src/index.ts +++ b/plugins/notifications-backend-module-slack/src/index.ts @@ -22,3 +22,4 @@ export { ANNOTATION_SLACK_BOT_NOTIFY } from './lib'; export { notificationsModuleSlack as default } from './module'; +export * from './extensions'; 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 9490ff7dde..d9aef27e46 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts @@ -17,7 +17,7 @@ import { mockServices } from '@backstage/backend-test-utils'; import { SlackNotificationProcessor } from './SlackNotificationProcessor'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; -import { WebClient } from '@slack/web-api'; +import { KnownBlock, WebClient } from '@slack/web-api'; import { Entity } from '@backstage/catalog-model'; import pThrottle from 'p-throttle'; import { durationToMilliseconds } from '@backstage/types'; @@ -209,6 +209,43 @@ describe('SlackNotificationProcessor', () => { }); }); + it('should use a custom block kit renderer when provided', async () => { + const slack = new WebClient(); + const customBlocks: KnownBlock[] = [ + { + type: 'section', + text: { type: 'mrkdwn', text: 'Custom block' }, + }, + ]; + + const processor = SlackNotificationProcessor.fromConfig(config, { + auth, + logger, + catalog: catalogServiceMock({ + entities: DEFAULT_ENTITIES_RESPONSE.items, + }), + slack, + blockKitRenderer: () => customBlocks, + })[0]; + + await processor.processOptions({ + recipients: { type: 'entity', entityRef: 'group:default/mock' }, + payload: { title: 'notification' }, + }); + + expect(slack.chat.postMessage).toHaveBeenCalledWith({ + channel: 'C12345678', + text: 'notification', + attachments: [ + { + color: '#00A699', + blocks: customBlocks, + fallback: 'notification', + }, + ], + }); + }); + describe('when a user notification is sent directly', () => { it('should send a notification to a user', 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 e8f8d9eda4..3bc833bcab 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts @@ -37,6 +37,7 @@ import { ANNOTATION_SLACK_BOT_NOTIFY } from './constants'; import { BroadcastRoute } from './types'; import { ExpiryMap, toChatPostMessageArgs } from './util'; import { CatalogService } from '@backstage/plugin-catalog-node'; +import { SlackBlockKitRenderer } from '../extensions'; export class SlackNotificationProcessor implements NotificationProcessor { private readonly logger: LoggerService; @@ -54,6 +55,7 @@ export class SlackNotificationProcessor implements NotificationProcessor { private readonly username?: string; private readonly concurrencyLimit: number; private readonly throttleInterval: number; + private readonly blockKitRenderer?: SlackBlockKitRenderer; static fromConfig( config: Config, @@ -63,6 +65,7 @@ export class SlackNotificationProcessor implements NotificationProcessor { catalog: CatalogService; slack?: WebClient; broadcastChannels?: string[]; + blockKitRenderer?: SlackBlockKitRenderer; }, ): SlackNotificationProcessor[] { const slackConfig = @@ -104,6 +107,7 @@ export class SlackNotificationProcessor implements NotificationProcessor { username?: string; concurrencyLimit?: number; throttleInterval?: number; + blockKitRenderer?: SlackBlockKitRenderer; }) { const { auth, @@ -115,6 +119,7 @@ export class SlackNotificationProcessor implements NotificationProcessor { username, concurrencyLimit, throttleInterval, + blockKitRenderer, } = options; this.logger = logger; this.catalog = catalog; @@ -126,6 +131,7 @@ export class SlackNotificationProcessor implements NotificationProcessor { this.concurrencyLimit = concurrencyLimit ?? 10; this.throttleInterval = throttleInterval ?? durationToMilliseconds({ minutes: 1 }); + this.blockKitRenderer = blockKitRenderer; this.entityLoader = new DataLoader( async entityRefs => { @@ -246,6 +252,7 @@ export class SlackNotificationProcessor implements NotificationProcessor { channel, payload: options.payload, username: this.username, + blockKitRenderer: this.blockKitRenderer, }); this.logger.debug( @@ -306,6 +313,7 @@ export class SlackNotificationProcessor implements NotificationProcessor { channel, payload: formattedPayload, username: this.username, + blockKitRenderer: this.blockKitRenderer, }), ); diff --git a/plugins/notifications-backend-module-slack/src/lib/util.ts b/plugins/notifications-backend-module-slack/src/lib/util.ts index 33293ea24f..0c13b21be6 100644 --- a/plugins/notifications-backend-module-slack/src/lib/util.ts +++ b/plugins/notifications-backend-module-slack/src/lib/util.ts @@ -19,13 +19,16 @@ import { NotificationSeverity, } from '@backstage/plugin-notifications-common'; import { ChatPostMessageArguments, KnownBlock } from '@slack/web-api'; +import { SlackBlockKitRenderer } from '../extensions'; export function toChatPostMessageArgs(options: { channel: string; payload: NotificationPayload; username?: string; + blockKitRenderer?: SlackBlockKitRenderer; }): ChatPostMessageArguments { - const { channel, payload, username } = options; + const { channel, payload, username, blockKitRenderer } = options; + const blocks = (blockKitRenderer ?? toSlackBlockKit)(payload); const args: ChatPostMessageArguments = { channel, @@ -34,7 +37,7 @@ export function toChatPostMessageArgs(options: { attachments: [ { color: getColor(payload.severity), - blocks: toSlackBlockKit(payload), + blocks, fallback: payload.title, }, ], diff --git a/plugins/notifications-backend-module-slack/src/module.ts b/plugins/notifications-backend-module-slack/src/module.ts index ff783ef104..e3d6aebee5 100644 --- a/plugins/notifications-backend-module-slack/src/module.ts +++ b/plugins/notifications-backend-module-slack/src/module.ts @@ -20,6 +20,10 @@ import { import { notificationsProcessingExtensionPoint } from '@backstage/plugin-notifications-node'; import { SlackNotificationProcessor } from './lib/SlackNotificationProcessor'; import { catalogServiceRef } from '@backstage/plugin-catalog-node'; +import { + notificationsSlackBlockKitExtensionPoint, + SlackBlockKitRenderer, +} from './extensions'; /** * The Slack notification processor for use with the notifications plugin. @@ -31,6 +35,16 @@ export const notificationsModuleSlack = createBackendModule({ pluginId: 'notifications', moduleId: 'slack', register(reg) { + let blockKitRenderer: SlackBlockKitRenderer | undefined; + reg.registerExtensionPoint(notificationsSlackBlockKitExtensionPoint, { + setBlockKitRenderer(renderer) { + if (blockKitRenderer) { + throw new Error(`Slack block kit renderer was already registered`); + } + blockKitRenderer = renderer; + }, + }); + reg.registerInit({ deps: { auth: coreServices.auth, @@ -45,6 +59,7 @@ export const notificationsModuleSlack = createBackendModule({ auth, logger, catalog, + blockKitRenderer, }), ); },