From cd62d78b78d26747363d0a0193fdb29cb60e68f0 Mon Sep 17 00:00:00 2001 From: Kai Dubauskas Date: Sat, 31 Jan 2026 13:08:10 -0500 Subject: [PATCH 1/9] fix: skipped user DMs when group is present Closes #32584 Signed-off-by: Kai Dubauskas --- .changeset/curvy-socks-punch.md | 5 ++ .../lib/SlackNotificationProcessor.test.ts | 77 +++++++++++++++++++ .../src/lib/SlackNotificationProcessor.ts | 44 +++++++++-- .../src/lib/constants.ts | 6 ++ 4 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 .changeset/curvy-socks-punch.md diff --git a/.changeset/curvy-socks-punch.md b/.changeset/curvy-socks-punch.md new file mode 100644 index 0000000000..6933ad8847 --- /dev/null +++ b/.changeset/curvy-socks-punch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-notifications-backend-module-slack': patch +--- + +Fix skipped slack DMS for users when a group entity is a recipient 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..e5dfc22754 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts @@ -16,6 +16,7 @@ import { mockServices } from '@backstage/backend-test-utils'; import { SlackNotificationProcessor } from './SlackNotificationProcessor'; +import { USER_REFS_FROM_REQUEST_KEY } from './constants'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; import { WebClient } from '@slack/web-api'; import { Entity } from '@backstage/catalog-model'; @@ -318,6 +319,82 @@ describe('SlackNotificationProcessor', () => { }); }); + describe('when group and user recipients are both provided', () => { + it('should send a group message and only DM explicit users', async () => { + const slack = new WebClient(); + const extraUser: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'other', + namespace: 'default', + annotations: { + 'slack.com/bot-notify': 'U99999999', + }, + }, + spec: {}, + }; + + const processor = SlackNotificationProcessor.fromConfig(config, { + auth, + logger, + catalog: catalogServiceMock({ + entities: [...DEFAULT_ENTITIES_RESPONSE.items, extraUser], + }), + slack, + })[0]; + + const processedOptions = await processor.processOptions({ + recipients: { + type: 'entity', + entityRef: ['group:default/mock', 'user:default/mock'], + }, + payload: { title: 'notification' }, + }); + + expect(processedOptions.payload.metadata).toEqual( + expect.objectContaining({ + [USER_REFS_FROM_REQUEST_KEY]: ['user:default/mock'], + }), + ); + + await processor.postProcess( + { + origin: 'plugin', + id: 'group-user-1', + user: 'user:default/other', + created: new Date(), + payload: { + title: 'notification', + link: '/catalog/user/default/other', + }, + }, + processedOptions, + ); + + expect(slack.chat.postMessage).toHaveBeenCalledTimes(1); + + await processor.postProcess( + { + origin: 'plugin', + id: 'explicit-user-1', + user: 'user:default/mock', + created: new Date(), + payload: { + title: 'notification', + link: '/catalog/user/default/mock', + }, + }, + processedOptions, + ); + + expect(slack.chat.postMessage).toHaveBeenCalledTimes(2); + expect(slack.chat.postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ channel: 'U12345678' }), + ); + }); + }); + describe('when broadcast channels are not configured', () => { it('should not send broadcast messages', 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..e1df3b0db9 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts @@ -19,6 +19,7 @@ import { Entity, isUserEntity, parseEntityRef, + stringifyEntityRef, UserEntity, } from '@backstage/catalog-model'; import { Config, readDurationFromConfig } from '@backstage/config'; @@ -33,7 +34,10 @@ import { Counter, metrics } from '@opentelemetry/api'; import { ChatPostMessageArguments, WebClient } from '@slack/web-api'; import DataLoader from 'dataloader'; import pThrottle from 'p-throttle'; -import { ANNOTATION_SLACK_BOT_NOTIFY } from './constants'; +import { + ANNOTATION_SLACK_BOT_NOTIFY, + USER_REFS_FROM_REQUEST_KEY, +} from './constants'; import { BroadcastRoute } from './types'; import { ExpiryMap, toChatPostMessageArgs } from './util'; import { CatalogService } from '@backstage/plugin-catalog-node'; @@ -210,12 +214,14 @@ export class SlackNotificationProcessor implements NotificationProcessor { const entityRefs = [options.recipients.entityRef].flat(); + const userEntityRefs: string[] = []; const outbound: ChatPostMessageArguments[] = []; await Promise.all( entityRefs.map(async entityRef => { const compoundEntityRef = parseEntityRef(entityRef); // skip users as they are sent direct messages if (compoundEntityRef.kind === 'user') { + userEntityRefs.push(stringifyEntityRef(compoundEntityRef)); return; } @@ -257,7 +263,18 @@ export class SlackNotificationProcessor implements NotificationProcessor { await this.sendNotifications(outbound); - return options; + const enrichedPayload = { + ...options.payload, + metadata: { + ...options.payload.metadata, + [USER_REFS_FROM_REQUEST_KEY]: userEntityRefs, + }, + }; + + return { + ...options, + payload: enrichedPayload, + }; } async postProcess( @@ -272,10 +289,25 @@ export class SlackNotificationProcessor implements NotificationProcessor { destinations.push(...routedChannels); } else if (options.recipients.type === 'entity') { // Handle user-specific notification - const entityRefs = [options.recipients.entityRef].flat(); - if (entityRefs.some(e => parseEntityRef(e).kind === 'group')) { - // We've already dispatched a slack channel message, so let's not send a DM. - return; + const rawUserRefs = + options.payload.metadata?.[USER_REFS_FROM_REQUEST_KEY]; + + const normalizedUserRef = stringifyEntityRef( + parseEntityRef(notification.user), + ); + const hasValidUserRefs = + Array.isArray(rawUserRefs) && + rawUserRefs.every(ref => typeof ref === 'string'); + const explicitUserEntityRefs = hasValidUserRefs + ? rawUserRefs.map(ref => stringifyEntityRef(parseEntityRef(ref))) + : undefined; + + if (!explicitUserEntityRefs?.includes(normalizedUserRef)) { + const entityRefs = [options.recipients.entityRef].flat(); + if (entityRefs.some(e => parseEntityRef(e).kind === 'group')) { + // This user was resolved from a non-user entity and we've already send a channel DM so let's not send another. + return; + } } const destination = await this.getSlackNotificationTarget( diff --git a/plugins/notifications-backend-module-slack/src/lib/constants.ts b/plugins/notifications-backend-module-slack/src/lib/constants.ts index cdf052da7e..1fd81c8f61 100644 --- a/plugins/notifications-backend-module-slack/src/lib/constants.ts +++ b/plugins/notifications-backend-module-slack/src/lib/constants.ts @@ -27,3 +27,9 @@ * however IDs are preferred. */ export const ANNOTATION_SLACK_BOT_NOTIFY = 'slack.com/bot-notify'; + +/** + * @public + * Metadata key containing user entities before resolution + */ +export const USER_REFS_FROM_REQUEST_KEY = 'userRefsFromRequest'; From 5ca7dcefb94ec105273ccb1098b8ebb277747483 Mon Sep 17 00:00:00 2001 From: Kai Dubauskas Date: Sat, 31 Jan 2026 14:00:59 -0500 Subject: [PATCH 2/9] docs: tweak wording Signed-off-by: Kai Dubauskas --- plugins/notifications-backend-module-slack/src/lib/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/notifications-backend-module-slack/src/lib/constants.ts b/plugins/notifications-backend-module-slack/src/lib/constants.ts index 1fd81c8f61..2ad07af73b 100644 --- a/plugins/notifications-backend-module-slack/src/lib/constants.ts +++ b/plugins/notifications-backend-module-slack/src/lib/constants.ts @@ -30,6 +30,6 @@ export const ANNOTATION_SLACK_BOT_NOTIFY = 'slack.com/bot-notify'; /** * @public - * Metadata key containing user entities before resolution + * Metadata key containing a list of all user entities before resolution */ export const USER_REFS_FROM_REQUEST_KEY = 'userRefsFromRequest'; From ea329727f98555ddb9f0c28a69b8690d21bc121d Mon Sep 17 00:00:00 2001 From: Kai Dubauskas Date: Wed, 18 Feb 2026 16:28:39 -0500 Subject: [PATCH 3/9] check explicit user refs from options.recipients Signed-off-by: Kai Dubauskas --- .../lib/SlackNotificationProcessor.test.ts | 58 +++++-------------- .../src/lib/SlackNotificationProcessor.ts | 45 ++++---------- .../src/lib/constants.ts | 6 -- 3 files changed, 25 insertions(+), 84 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 e5dfc22754..41c5939532 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts @@ -16,7 +16,6 @@ import { mockServices } from '@backstage/backend-test-utils'; import { SlackNotificationProcessor } from './SlackNotificationProcessor'; -import { USER_REFS_FROM_REQUEST_KEY } from './constants'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; import { WebClient } from '@slack/web-api'; import { Entity } from '@backstage/catalog-model'; @@ -319,32 +318,20 @@ describe('SlackNotificationProcessor', () => { }); }); - describe('when group and user recipients are both provided', () => { - it('should send a group message and only DM explicit users', async () => { + describe('when recipients include both users and a group', () => { + it('should still DM explicit user recipients', async () => { const slack = new WebClient(); - const extraUser: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', - metadata: { - name: 'other', - namespace: 'default', - annotations: { - 'slack.com/bot-notify': 'U99999999', - }, - }, - spec: {}, - }; const processor = SlackNotificationProcessor.fromConfig(config, { auth, logger, catalog: catalogServiceMock({ - entities: [...DEFAULT_ENTITIES_RESPONSE.items, extraUser], + entities: DEFAULT_ENTITIES_RESPONSE.items, }), slack, })[0]; - const processedOptions = await processor.processOptions({ + await processor.processOptions({ recipients: { type: 'entity', entityRef: ['group:default/mock', 'user:default/mock'], @@ -352,44 +339,25 @@ describe('SlackNotificationProcessor', () => { payload: { title: 'notification' }, }); - expect(processedOptions.payload.metadata).toEqual( - expect.objectContaining({ - [USER_REFS_FROM_REQUEST_KEY]: ['user:default/mock'], - }), - ); - - await processor.postProcess( - { - origin: 'plugin', - id: 'group-user-1', - user: 'user:default/other', - created: new Date(), - payload: { - title: 'notification', - link: '/catalog/user/default/other', - }, - }, - processedOptions, - ); - - expect(slack.chat.postMessage).toHaveBeenCalledTimes(1); - await processor.postProcess( { origin: 'plugin', id: 'explicit-user-1', user: 'user:default/mock', created: new Date(), - payload: { - title: 'notification', - link: '/catalog/user/default/mock', - }, + payload: { title: 'notification' }, + }, + { + recipients: { + type: 'entity', + entityRef: ['group:default/mock', 'user:default/mock'], + }, + payload: { title: 'notification' }, }, - processedOptions, ); expect(slack.chat.postMessage).toHaveBeenCalledTimes(2); - expect(slack.chat.postMessage).toHaveBeenLastCalledWith( + expect(slack.chat.postMessage).toHaveBeenCalledWith( expect.objectContaining({ 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 e1df3b0db9..648bc5aa80 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts @@ -34,10 +34,7 @@ import { Counter, metrics } from '@opentelemetry/api'; import { ChatPostMessageArguments, WebClient } from '@slack/web-api'; import DataLoader from 'dataloader'; import pThrottle from 'p-throttle'; -import { - ANNOTATION_SLACK_BOT_NOTIFY, - USER_REFS_FROM_REQUEST_KEY, -} from './constants'; +import { ANNOTATION_SLACK_BOT_NOTIFY } from './constants'; import { BroadcastRoute } from './types'; import { ExpiryMap, toChatPostMessageArgs } from './util'; import { CatalogService } from '@backstage/plugin-catalog-node'; @@ -214,14 +211,12 @@ export class SlackNotificationProcessor implements NotificationProcessor { const entityRefs = [options.recipients.entityRef].flat(); - const userEntityRefs: string[] = []; const outbound: ChatPostMessageArguments[] = []; await Promise.all( entityRefs.map(async entityRef => { const compoundEntityRef = parseEntityRef(entityRef); // skip users as they are sent direct messages if (compoundEntityRef.kind === 'user') { - userEntityRefs.push(stringifyEntityRef(compoundEntityRef)); return; } @@ -263,18 +258,7 @@ export class SlackNotificationProcessor implements NotificationProcessor { await this.sendNotifications(outbound); - const enrichedPayload = { - ...options.payload, - metadata: { - ...options.payload.metadata, - [USER_REFS_FROM_REQUEST_KEY]: userEntityRefs, - }, - }; - - return { - ...options, - payload: enrichedPayload, - }; + return options; } async postProcess( @@ -289,25 +273,20 @@ export class SlackNotificationProcessor implements NotificationProcessor { destinations.push(...routedChannels); } else if (options.recipients.type === 'entity') { // Handle user-specific notification - const rawUserRefs = - options.payload.metadata?.[USER_REFS_FROM_REQUEST_KEY]; - + const entityRefs = [options.recipients.entityRef].flat(); + const explicitUserEntityRefs = entityRefs + .filter(entityRef => parseEntityRef(entityRef).kind === 'user') + .map(entityRef => stringifyEntityRef(parseEntityRef(entityRef))); const normalizedUserRef = stringifyEntityRef( parseEntityRef(notification.user), ); - const hasValidUserRefs = - Array.isArray(rawUserRefs) && - rawUserRefs.every(ref => typeof ref === 'string'); - const explicitUserEntityRefs = hasValidUserRefs - ? rawUserRefs.map(ref => stringifyEntityRef(parseEntityRef(ref))) - : undefined; - if (!explicitUserEntityRefs?.includes(normalizedUserRef)) { - const entityRefs = [options.recipients.entityRef].flat(); - if (entityRefs.some(e => parseEntityRef(e).kind === 'group')) { - // This user was resolved from a non-user entity and we've already send a channel DM so let's not send another. - return; - } + if ( + entityRefs.some(e => parseEntityRef(e).kind === 'group') && + !explicitUserEntityRefs.includes(normalizedUserRef) + ) { + // This user was resolved from a non-user entity and we've already sent a group channel message. + return; } const destination = await this.getSlackNotificationTarget( diff --git a/plugins/notifications-backend-module-slack/src/lib/constants.ts b/plugins/notifications-backend-module-slack/src/lib/constants.ts index 2ad07af73b..cdf052da7e 100644 --- a/plugins/notifications-backend-module-slack/src/lib/constants.ts +++ b/plugins/notifications-backend-module-slack/src/lib/constants.ts @@ -27,9 +27,3 @@ * however IDs are preferred. */ export const ANNOTATION_SLACK_BOT_NOTIFY = 'slack.com/bot-notify'; - -/** - * @public - * Metadata key containing a list of all user entities before resolution - */ -export const USER_REFS_FROM_REQUEST_KEY = 'userRefsFromRequest'; From 12a0ccb278e46e71e75602f1b18413d4852093e6 Mon Sep 17 00:00:00 2001 From: Kai Dubauskas Date: Thu, 5 Mar 2026 18:14:42 -0500 Subject: [PATCH 4/9] only dm original user entities Signed-off-by: Kai Dubauskas --- .changeset/curvy-socks-punch.md | 4 ++-- .../src/lib/SlackNotificationProcessor.ts | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.changeset/curvy-socks-punch.md b/.changeset/curvy-socks-punch.md index 6933ad8847..2d06b292ec 100644 --- a/.changeset/curvy-socks-punch.md +++ b/.changeset/curvy-socks-punch.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-notifications-backend-module-slack': patch +'@backstage/plugin-notifications-backend-module-slack': minor --- -Fix skipped slack DMS for users when a group entity is a recipient +**BREAKING**: Notifications sent to non-user entities no longer send Slack DMs to resolved users. diff --git a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts index 648bc5aa80..897e0feabe 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts @@ -281,11 +281,8 @@ export class SlackNotificationProcessor implements NotificationProcessor { parseEntityRef(notification.user), ); - if ( - entityRefs.some(e => parseEntityRef(e).kind === 'group') && - !explicitUserEntityRefs.includes(normalizedUserRef) - ) { - // This user was resolved from a non-user entity and we've already sent a group channel message. + if (!explicitUserEntityRefs.includes(normalizedUserRef)) { + // This user was resolved from a non-user entity. Skip sending aDM. return; } From 878e73bd4b37ffec14cc80be1f01a0f081db7b0a Mon Sep 17 00:00:00 2001 From: Kai Dubauskas Date: Thu, 5 Mar 2026 18:17:37 -0500 Subject: [PATCH 5/9] changelog wording Signed-off-by: Kai Dubauskas --- .changeset/curvy-socks-punch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/curvy-socks-punch.md b/.changeset/curvy-socks-punch.md index 2d06b292ec..bc6cde8389 100644 --- a/.changeset/curvy-socks-punch.md +++ b/.changeset/curvy-socks-punch.md @@ -2,4 +2,4 @@ '@backstage/plugin-notifications-backend-module-slack': minor --- -**BREAKING**: Notifications sent to non-user entities no longer send Slack DMs to resolved users. +**BREAKING**: Notifications sent to non-user entities no longer send Slack DMs to resolved users, while notifications sent to explicit user entity recipients still send DMs. From 2eec3ecc09571c07f5fbc36689e47b630b7a1c36 Mon Sep 17 00:00:00 2001 From: Kai Dubauskas Date: Thu, 5 Mar 2026 18:18:04 -0500 Subject: [PATCH 6/9] changelog wording Signed-off-by: Kai Dubauskas --- .changeset/curvy-socks-punch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/curvy-socks-punch.md b/.changeset/curvy-socks-punch.md index bc6cde8389..a91af80d33 100644 --- a/.changeset/curvy-socks-punch.md +++ b/.changeset/curvy-socks-punch.md @@ -2,4 +2,4 @@ '@backstage/plugin-notifications-backend-module-slack': minor --- -**BREAKING**: Notifications sent to non-user entities no longer send Slack DMs to resolved users, while notifications sent to explicit user entity recipients still send DMs. +**BREAKING**: Only send DMs to user entity recipients. Notifications sent to non-user entities no longer send Slack DMs to resolved users. From 047c49a971ad818ff058d967855ddefa3e68f6f6 Mon Sep 17 00:00:00 2001 From: Kai Dubauskas Date: Thu, 5 Mar 2026 18:19:06 -0500 Subject: [PATCH 7/9] fix comment Signed-off-by: Kai Dubauskas --- .../src/lib/SlackNotificationProcessor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts index 897e0feabe..ac7933eee6 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts @@ -282,7 +282,7 @@ export class SlackNotificationProcessor implements NotificationProcessor { ); if (!explicitUserEntityRefs.includes(normalizedUserRef)) { - // This user was resolved from a non-user entity. Skip sending aDM. + // This user was resolved from a non-user entity. Skip sending a DM. return; } From 2fbdbc1679be6411529da7cfd5e9bd58084ad0cd Mon Sep 17 00:00:00 2001 From: Kai Dubauskas Date: Thu, 5 Mar 2026 18:34:55 -0500 Subject: [PATCH 8/9] retrigger CI Signed-off-by: Kai Dubauskas From a49c59f2da804cd5dab51e44b84c02ec429c17c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 10 Mar 2026 14:56:07 +0100 Subject: [PATCH 9/9] Update .changeset/curvy-socks-punch.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/curvy-socks-punch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/curvy-socks-punch.md b/.changeset/curvy-socks-punch.md index a91af80d33..a353824c13 100644 --- a/.changeset/curvy-socks-punch.md +++ b/.changeset/curvy-socks-punch.md @@ -2,4 +2,4 @@ '@backstage/plugin-notifications-backend-module-slack': minor --- -**BREAKING**: Only send DMs to user entity recipients. Notifications sent to non-user entities no longer send Slack DMs to resolved users. +**BREAKING**: Only send direct messages to user entity recipients. Notifications sent to non-user entities no longer send Slack direct messages to resolved users.