From 3d09bb218aa4c565cfa4ab6a1b3f1500a8556d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Edeg=C3=A5rd?= Date: Wed, 17 Sep 2025 11:30:28 +0000 Subject: [PATCH 1/3] Adds username as optional config in order to send with a different username than the Slack App have. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Henrik Edegård --- .changeset/heavy-cooks-divide.md | 5 + docs/notifications/processors.md | 1 + .../lib/SlackNotificationProcessor.test.ts | 291 ++++++++++++++++++ .../src/lib/SlackNotificationProcessor.ts | 15 +- .../src/lib/util.ts | 4 +- 5 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 .changeset/heavy-cooks-divide.md diff --git a/.changeset/heavy-cooks-divide.md b/.changeset/heavy-cooks-divide.md new file mode 100644 index 0000000000..181cd01075 --- /dev/null +++ b/.changeset/heavy-cooks-divide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-notifications-backend-module-slack': minor +--- + +Adds username as optional config in order to send Slack notifications with a specific username in the case when using one Slack App for more than just Backstage. diff --git a/docs/notifications/processors.md b/docs/notifications/processors.md index ad3e427364..c25b669ed6 100644 --- a/docs/notifications/processors.md +++ b/docs/notifications/processors.md @@ -148,6 +148,7 @@ notifications: - token: xoxb-XXXXXXXXX broadcastChannels: # Optional, if you wish to support broadcast notifications. - C12345678 + username: 'Backstage Bot' # Optional, defaults to the name of the Slack App. ``` Multiple instances can be added in the `slack` array, allowing you to have multiple configurations if you need to send 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 45a74cee70..c970aec7ba 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts @@ -542,6 +542,297 @@ describe('SlackNotificationProcessor', () => { }); }); + describe('when username is configured', () => { + it('should include username in group messages', async () => { + const slack = new WebClient(); + const usernameConfig = mockServices.rootConfig({ + data: { + app: { + baseUrl: 'https://example.org', + }, + notifications: { + processors: { + slack: [ + { + token: 'mock-token', + username: 'BackstageBot', + }, + ], + }, + }, + }, + }); + + const processor = SlackNotificationProcessor.fromConfig(usernameConfig, { + auth, + logger, + catalog: catalogServiceMock({ + entities: DEFAULT_ENTITIES_RESPONSE.items, + }), + slack, + })[0]; + + await processor.processOptions({ + recipients: { type: 'entity', entityRef: 'group:default/mock' }, + payload: { title: 'notification' }, + }); + + expect(slack.chat.postMessage).toHaveBeenCalledWith({ + channel: 'C12345678', + text: 'notification', + username: 'BackstageBot', + attachments: [ + { + color: '#00A699', + blocks: [ + { + type: 'section', + text: { + text: 'No description provided', + type: 'mrkdwn', + }, + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'View More', + }, + action_id: 'button-action', + }, + }, + { + type: 'context', + elements: [ + { + type: 'plain_text', + text: 'Severity: normal', + emoji: true, + }, + { + type: 'plain_text', + text: 'Topic: N/A', + emoji: true, + }, + ], + }, + ], + fallback: 'notification', + }, + ], + }); + }); + + it('should include username in direct user messages', async () => { + const slack = new WebClient(); + const usernameConfig = mockServices.rootConfig({ + data: { + app: { + baseUrl: 'https://example.org', + }, + notifications: { + processors: { + slack: [ + { + token: 'mock-token', + username: 'BackstageBot', + }, + ], + }, + }, + }, + }); + + const processor = SlackNotificationProcessor.fromConfig(usernameConfig, { + auth, + logger, + catalog: catalogServiceMock({ + entities: DEFAULT_ENTITIES_RESPONSE.items, + }), + slack, + })[0]; + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: 'user:default/mock', + created: new Date(), + payload: { + title: 'notification', + link: '/catalog/user/default/jane.doe', + }, + }, + { + recipients: { type: 'entity', entityRef: 'user:default/mock' }, + payload: { title: 'notification' }, + }, + ); + + expect(slack.chat.postMessage).toHaveBeenCalledWith({ + channel: 'U12345678', + text: 'notification', + username: 'BackstageBot', + attachments: [ + { + color: '#00A699', + blocks: [ + { + type: 'section', + text: { + text: 'No description provided', + type: 'mrkdwn', + }, + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'View More', + }, + action_id: 'button-action', + }, + }, + { + type: 'context', + elements: [ + { + type: 'plain_text', + text: 'Severity: normal', + emoji: true, + }, + { + type: 'plain_text', + text: 'Topic: N/A', + emoji: true, + }, + ], + }, + ], + fallback: 'notification', + }, + ], + }); + }); + + it('should include username in broadcast messages', async () => { + const slack = new WebClient(); + const usernameAndBroadcastConfig = mockServices.rootConfig({ + data: { + app: { + baseUrl: 'https://example.org', + }, + notifications: { + processors: { + slack: [ + { + token: 'mock-token', + username: 'BackstageBot', + broadcastChannels: ['C12345678'], + }, + ], + }, + }, + }, + }); + + const processor = SlackNotificationProcessor.fromConfig( + usernameAndBroadcastConfig, + { + auth, + logger, + catalog: catalogServiceMock({ + entities: DEFAULT_ENTITIES_RESPONSE.items, + }), + slack, + }, + )[0]; + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: null, + created: new Date(), + payload: { + title: 'notification', + link: '/catalog/user/default/jane.doe', + }, + }, + { + recipients: { type: 'broadcast' }, + payload: { title: 'notification' }, + }, + ); + + expect(slack.chat.postMessage).toHaveBeenCalledWith({ + channel: 'C12345678', + text: 'notification', + username: 'BackstageBot', + attachments: [ + { + color: '#00A699', + blocks: [ + { + type: 'section', + text: { + text: 'No description provided', + type: 'mrkdwn', + }, + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'View More', + }, + action_id: 'button-action', + }, + }, + { + type: 'context', + elements: [ + { + type: 'plain_text', + text: 'Severity: normal', + emoji: true, + }, + { + type: 'plain_text', + text: 'Topic: N/A', + emoji: true, + }, + ], + }, + ], + fallback: 'notification', + }, + ], + }); + }); + }); + + describe('when username is not configured', () => { + it('should not include username in messages', async () => { + const slack = new WebClient(); + + const processor = SlackNotificationProcessor.fromConfig(config, { + auth, + logger, + catalog: catalogServiceMock({ + entities: DEFAULT_ENTITIES_RESPONSE.items, + }), + slack, + })[0]; + + await processor.processOptions({ + recipients: { type: 'entity', entityRef: 'group:default/mock' }, + payload: { title: 'notification' }, + }); + + const calls = (slack.chat.postMessage as jest.Mock).mock.calls; + expect(calls).toHaveLength(1); + expect(calls[0][0]).not.toHaveProperty('username'); + }); + }); + describe('when replacing user entity refs with Slack IDs', () => { const createBaseMessage = (text: string) => ({ channel: 'U12345678', diff --git a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts index f7dbc6a93e..f050b3b677 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts @@ -49,6 +49,7 @@ export class SlackNotificationProcessor implements NotificationProcessor { private readonly messagesFailed: Counter; private readonly broadcastChannels?: string[]; private readonly entityLoader: DataLoader; + private readonly username?: string; static fromConfig( config: Config, @@ -66,9 +67,11 @@ export class SlackNotificationProcessor implements NotificationProcessor { const token = c.getString('token'); const slack = options.slack ?? new WebClient(token); const broadcastChannels = c.getOptionalStringArray('broadcastChannels'); + const username = c.getOptionalString('username'); return new SlackNotificationProcessor({ slack, broadcastChannels, + username, ...options, }); }); @@ -80,13 +83,16 @@ export class SlackNotificationProcessor implements NotificationProcessor { logger: LoggerService; catalog: CatalogService; broadcastChannels?: string[]; + username?: string; }) { - const { auth, catalog, logger, slack, broadcastChannels } = options; + const { auth, catalog, logger, slack, broadcastChannels, username } = + options; this.logger = logger; this.catalog = catalog; this.auth = auth; this.slack = slack; this.broadcastChannels = broadcastChannels; + this.username = username; this.entityLoader = new DataLoader( async entityRefs => { @@ -206,6 +212,7 @@ export class SlackNotificationProcessor implements NotificationProcessor { const payload = toChatPostMessageArgs({ channel, payload: options.payload, + ...(this.username && { username: this.username }), }); this.logger.debug( @@ -261,7 +268,11 @@ export class SlackNotificationProcessor implements NotificationProcessor { options.payload, ); const outbound = destinations.map(channel => - toChatPostMessageArgs({ channel, payload: formattedPayload }), + toChatPostMessageArgs({ + channel, + payload: formattedPayload, + ...(this.username && { username: this.username }), + }), ); // Log debug info diff --git a/plugins/notifications-backend-module-slack/src/lib/util.ts b/plugins/notifications-backend-module-slack/src/lib/util.ts index 24f9f6a964..1116218b09 100644 --- a/plugins/notifications-backend-module-slack/src/lib/util.ts +++ b/plugins/notifications-backend-module-slack/src/lib/util.ts @@ -23,12 +23,14 @@ import { ChatPostMessageArguments, KnownBlock } from '@slack/web-api'; export function toChatPostMessageArgs(options: { channel: string; payload: NotificationPayload; + username?: string; }): ChatPostMessageArguments { - const { channel, payload } = options; + const { channel, payload, username } = options; const args: ChatPostMessageArguments = { channel, text: payload.title, + ...(username && { username }), attachments: [ { color: getColor(payload.severity), From 93e1aa0133e9229b0257c38b0a5b3e5ce23b531a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Edeg=C3=A5rd?= Date: Wed, 24 Sep 2025 07:23:01 +0000 Subject: [PATCH 2/3] Simplify username usage. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Henrik Edegård --- .../src/lib/SlackNotificationProcessor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts index f050b3b677..96d4b2ff3e 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts @@ -212,7 +212,7 @@ export class SlackNotificationProcessor implements NotificationProcessor { const payload = toChatPostMessageArgs({ channel, payload: options.payload, - ...(this.username && { username: this.username }), + username: this.username, }); this.logger.debug( @@ -271,7 +271,7 @@ export class SlackNotificationProcessor implements NotificationProcessor { toChatPostMessageArgs({ channel, payload: formattedPayload, - ...(this.username && { username: this.username }), + username: this.username, }), ); From 49be37008ce3b553d5b96f72fdc71b22e664f464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Edeg=C3=A5rd?= Date: Wed, 24 Sep 2025 07:55:22 +0000 Subject: [PATCH 3/3] Simplified more. The Slack library will remove undefined values when sending the actual request. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Henrik Edegård --- .../src/lib/SlackNotificationProcessor.test.ts | 2 +- plugins/notifications-backend-module-slack/src/lib/util.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 c970aec7ba..ad4bf01e9f 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts @@ -829,7 +829,7 @@ describe('SlackNotificationProcessor', () => { const calls = (slack.chat.postMessage as jest.Mock).mock.calls; expect(calls).toHaveLength(1); - expect(calls[0][0]).not.toHaveProperty('username'); + expect(calls[0][0].username).toBeUndefined(); }); }); diff --git a/plugins/notifications-backend-module-slack/src/lib/util.ts b/plugins/notifications-backend-module-slack/src/lib/util.ts index 1116218b09..33293ea24f 100644 --- a/plugins/notifications-backend-module-slack/src/lib/util.ts +++ b/plugins/notifications-backend-module-slack/src/lib/util.ts @@ -30,7 +30,7 @@ export function toChatPostMessageArgs(options: { const args: ChatPostMessageArguments = { channel, text: payload.title, - ...(username && { username }), + username, attachments: [ { color: getColor(payload.severity),