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),