Adds username as optional config in order to send with a different username than the Slack App have.

Signed-off-by: Henrik Edegård <henrik.edegard@fortnox.se>
This commit is contained in:
Henrik Edegård
2025-09-17 11:30:28 +00:00
parent fcc5e8473b
commit 3d09bb218a
5 changed files with 313 additions and 3 deletions
+5
View File
@@ -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.
+1
View File
@@ -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
@@ -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',
@@ -49,6 +49,7 @@ export class SlackNotificationProcessor implements NotificationProcessor {
private readonly messagesFailed: Counter;
private readonly broadcastChannels?: string[];
private readonly entityLoader: DataLoader<string, Entity | undefined>;
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<string, Entity | undefined>(
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
@@ -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),