Merge pull request #29308 from kunickiaj/slack-notifications-processor
Add Slack Notification Processor
This commit is contained in:
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,5 @@
|
||||
# @backstage/plugin-notifications-backend-module-slack
|
||||
|
||||
The Slack backend module for the notifications plugin.
|
||||
|
||||
See [Built-in Processors](https://backstage.io/docs/notifications/processors/#built-in-processors) for detailed documentation
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: backstage-plugin-notifications-backend-module-slack
|
||||
title: '@backstage/plugin-notifications-backend-module-slack'
|
||||
description: The slack backend module for the notifications plugin.
|
||||
spec:
|
||||
lifecycle: experimental
|
||||
type: backstage-backend-plugin-module
|
||||
owner: maintainers
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
export interface Config {
|
||||
notifications?: {
|
||||
processors?: {
|
||||
slack?: Array<{
|
||||
/**
|
||||
* Slack Bot Token. Usually starts with `xoxb-`.
|
||||
* @visibility secret
|
||||
*/
|
||||
token?: string;
|
||||
/**
|
||||
* Broadcast notification receivers when receiver is set to config
|
||||
* These can be Slack User IDs, Slack User Email addresses, Slack Channel
|
||||
* Names, or Slack Channel IDs. Any valid identifier that chat.postMessage can accept.
|
||||
*/
|
||||
broadcastChannels?: string[];
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@backstage/plugin-notifications-backend-module-slack",
|
||||
"version": "0.0.0",
|
||||
"description": "The slack backend module for the notifications plugin.",
|
||||
"backstage": {
|
||||
"role": "backend-plugin-module",
|
||||
"pluginId": "notifications",
|
||||
"pluginPackage": "@backstage/plugin-notifications-backend"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "plugins/notifications-backend-module-slack"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"config.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
"clean": "backstage-cli package clean",
|
||||
"lint": "backstage-cli package lint",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack",
|
||||
"start": "backstage-cli package start",
|
||||
"test": "backstage-cli package test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/catalog-client": "workspace:^",
|
||||
"@backstage/catalog-model": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/plugin-notifications-common": "workspace:^",
|
||||
"@backstage/plugin-notifications-node": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@slack/bolt": "^3.21.4",
|
||||
"@slack/types": "^2.14.0",
|
||||
"@slack/web-api": "^7.5.0",
|
||||
"dataloader": "^2.0.0",
|
||||
"p-throttle": "^4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/plugin-catalog-node": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"msw": "^2.0.0"
|
||||
},
|
||||
"configSchema": "config.d.ts"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
## API Report File for "@backstage/plugin-notifications-backend-module-slack"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
|
||||
// @public
|
||||
export const ANNOTATION_SLACK_BOT_NOTIFY = 'slack.com/bot-notify';
|
||||
|
||||
// @public
|
||||
const notificationsModuleSlack: BackendFeature;
|
||||
export default notificationsModuleSlack;
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The slack backend module for the notifications plugin.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export { ANNOTATION_SLACK_BOT_NOTIFY } from './lib';
|
||||
export { notificationsModuleSlack as default } from './module';
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
/*
|
||||
* 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 { 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 { Entity } from '@backstage/catalog-model';
|
||||
|
||||
jest.mock('@slack/web-api', () => {
|
||||
const mockSlack = {
|
||||
chat: {
|
||||
postMessage: jest.fn(() => ({
|
||||
ok: true,
|
||||
ts: '1234567890.123456',
|
||||
channel: 'C12345678',
|
||||
})),
|
||||
},
|
||||
conversations: {
|
||||
list: jest.fn(() => ({
|
||||
ok: true,
|
||||
channels: [{ id: 'C12345678', name: 'test' }],
|
||||
})),
|
||||
},
|
||||
users: {
|
||||
list: jest.fn(() => ({
|
||||
ok: true,
|
||||
members: [
|
||||
{
|
||||
id: 'U12345678',
|
||||
name: 'test',
|
||||
profile: { email: 'test@example.com' },
|
||||
real_name: 'Test User',
|
||||
is_bot: false,
|
||||
is_app_user: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
};
|
||||
return { WebClient: jest.fn(() => mockSlack) };
|
||||
});
|
||||
|
||||
const DEFAULT_ENTITIES_RESPONSE = {
|
||||
items: [
|
||||
{
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'mock',
|
||||
namespace: 'default',
|
||||
annotations: {
|
||||
'slack.com/bot-notify': 'U12345678',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
owner: 'group:default/mock',
|
||||
},
|
||||
} as unknown as Entity,
|
||||
{
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'mock',
|
||||
namespace: 'default',
|
||||
annotations: {
|
||||
'slack.com/bot-notify': 'C12345678',
|
||||
},
|
||||
},
|
||||
} as unknown as Entity,
|
||||
],
|
||||
};
|
||||
|
||||
describe('SlackNotificationProcessor', () => {
|
||||
const logger = mockServices.logger.mock();
|
||||
const auth = mockServices.auth();
|
||||
const discovery = mockServices.discovery();
|
||||
const config = mockServices.rootConfig({
|
||||
data: {
|
||||
app: {
|
||||
baseUrl: 'https://example.org',
|
||||
},
|
||||
notifications: {
|
||||
processors: {
|
||||
slack: [
|
||||
{
|
||||
token: 'mock-token',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should send a notification to a group', async () => {
|
||||
const slack = new WebClient();
|
||||
|
||||
const processor = SlackNotificationProcessor.fromConfig(config, {
|
||||
auth,
|
||||
discovery,
|
||||
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',
|
||||
attachments: [
|
||||
{
|
||||
color: '#00A699',
|
||||
blocks: [
|
||||
{
|
||||
type: 'section',
|
||||
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 a user notification is sent directly', () => {
|
||||
it('should send a notification to a user', async () => {
|
||||
const slack = new WebClient();
|
||||
|
||||
const processor = SlackNotificationProcessor.fromConfig(config, {
|
||||
auth,
|
||||
discovery,
|
||||
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',
|
||||
attachments: [
|
||||
{
|
||||
color: '#00A699',
|
||||
blocks: [
|
||||
{
|
||||
type: 'section',
|
||||
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 a user notification is expanded from a group', () => {
|
||||
it('should not send a notification', async () => {
|
||||
const slack = new WebClient();
|
||||
|
||||
const processor = SlackNotificationProcessor.fromConfig(config, {
|
||||
auth,
|
||||
discovery,
|
||||
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: 'group:default/group' },
|
||||
payload: { title: 'notification' },
|
||||
},
|
||||
);
|
||||
|
||||
expect(slack.chat.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when broadcast channels are not configured', () => {
|
||||
it('should not send broadcast messages', async () => {
|
||||
const slack = new WebClient();
|
||||
|
||||
const processor = SlackNotificationProcessor.fromConfig(config, {
|
||||
auth,
|
||||
discovery,
|
||||
logger,
|
||||
catalog: catalogServiceMock({
|
||||
entities: DEFAULT_ENTITIES_RESPONSE.items,
|
||||
}),
|
||||
slack,
|
||||
})[0];
|
||||
|
||||
await processor.processOptions({
|
||||
recipients: { type: 'broadcast' },
|
||||
payload: { title: 'notification' },
|
||||
});
|
||||
|
||||
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).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when broadcast channels are configured', () => {
|
||||
it('should send broadcast messages', async () => {
|
||||
const slack = new WebClient();
|
||||
const broadcastConfig = mockServices.rootConfig({
|
||||
data: {
|
||||
app: {
|
||||
baseUrl: 'https://example.org',
|
||||
},
|
||||
notifications: {
|
||||
processors: {
|
||||
slack: [
|
||||
{
|
||||
token: 'mock-token',
|
||||
broadcastChannels: ['C12345678', 'D12345678'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const processor = SlackNotificationProcessor.fromConfig(broadcastConfig, {
|
||||
auth,
|
||||
discovery,
|
||||
logger,
|
||||
catalog: catalogServiceMock({
|
||||
entities: DEFAULT_ENTITIES_RESPONSE.items,
|
||||
}),
|
||||
slack,
|
||||
})[0];
|
||||
|
||||
await processor.processOptions({
|
||||
recipients: { type: 'broadcast' },
|
||||
payload: { title: 'notification' },
|
||||
});
|
||||
|
||||
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).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* 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 {
|
||||
AuthService,
|
||||
DiscoveryService,
|
||||
LoggerService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { Entity, parseEntityRef } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import { Notification } from '@backstage/plugin-notifications-common';
|
||||
import {
|
||||
NotificationProcessor,
|
||||
NotificationSendOptions,
|
||||
} from '@backstage/plugin-notifications-node';
|
||||
import { durationToMilliseconds } from '@backstage/types';
|
||||
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 { toChatPostMessageArgs } from './util';
|
||||
|
||||
export class SlackNotificationProcessor implements NotificationProcessor {
|
||||
private readonly logger: LoggerService;
|
||||
private readonly catalog: CatalogApi;
|
||||
private readonly auth: AuthService;
|
||||
private readonly slack: WebClient;
|
||||
private readonly sendNotifications;
|
||||
private readonly messagesSent: Counter;
|
||||
private readonly messagesFailed: Counter;
|
||||
private readonly broadcastChannels?: string[];
|
||||
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options: {
|
||||
auth: AuthService;
|
||||
discovery: DiscoveryService;
|
||||
logger: LoggerService;
|
||||
catalog: CatalogApi;
|
||||
slack?: WebClient;
|
||||
broadcastChannels?: string[];
|
||||
},
|
||||
): SlackNotificationProcessor[] {
|
||||
const slackConfig =
|
||||
config.getOptionalConfigArray('notifications.processors.slack') ?? [];
|
||||
return slackConfig.map(c => {
|
||||
const token = c.getString('token');
|
||||
const slack = options.slack ?? new WebClient(token);
|
||||
const broadcastChannels = c.getOptionalStringArray('broadcastChannels');
|
||||
return new SlackNotificationProcessor({
|
||||
slack,
|
||||
broadcastChannels,
|
||||
...options,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private constructor(options: {
|
||||
slack: WebClient;
|
||||
auth: AuthService;
|
||||
discovery: DiscoveryService;
|
||||
logger: LoggerService;
|
||||
catalog: CatalogApi;
|
||||
broadcastChannels?: string[];
|
||||
}) {
|
||||
const { auth, catalog, logger, slack, broadcastChannels } = options;
|
||||
this.logger = logger;
|
||||
this.catalog = catalog;
|
||||
this.auth = auth;
|
||||
this.slack = slack;
|
||||
this.broadcastChannels = broadcastChannels;
|
||||
|
||||
const meter = metrics.getMeter('default');
|
||||
this.messagesSent = meter.createCounter(
|
||||
'notifications.processors.slack.sent.count',
|
||||
{
|
||||
description: 'Number of messages sent to Slack successfully',
|
||||
},
|
||||
);
|
||||
this.messagesFailed = meter.createCounter(
|
||||
'notifications.processors.slack.error.count',
|
||||
{
|
||||
description: 'Number of messages that failed to send to Slack',
|
||||
},
|
||||
);
|
||||
|
||||
const throttle = pThrottle({
|
||||
limit: 10,
|
||||
interval: durationToMilliseconds({ minutes: 1 }),
|
||||
});
|
||||
const throttled = throttle((opts: ChatPostMessageArguments) =>
|
||||
this.sendNotification(opts),
|
||||
);
|
||||
this.sendNotifications = async (opts: ChatPostMessageArguments[]) => {
|
||||
const results = await Promise.allSettled(
|
||||
opts.map(message => throttled(message)),
|
||||
);
|
||||
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
|
||||
results.forEach(result => {
|
||||
if (result.status === 'fulfilled') {
|
||||
successCount++;
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Failed to send Slack channel notification: ${result.reason.message}`,
|
||||
);
|
||||
failureCount++;
|
||||
}
|
||||
});
|
||||
|
||||
this.messagesSent.add(successCount);
|
||||
this.messagesFailed.add(failureCount);
|
||||
};
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
return 'SlackNotificationProcessor';
|
||||
}
|
||||
|
||||
async processOptions(
|
||||
options: NotificationSendOptions,
|
||||
): Promise<NotificationSendOptions> {
|
||||
if (options.recipients.type !== 'entity') {
|
||||
return options;
|
||||
}
|
||||
|
||||
const entityRefs = [options.recipients.entityRef].flat();
|
||||
|
||||
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') {
|
||||
return;
|
||||
}
|
||||
|
||||
let channel;
|
||||
try {
|
||||
channel = await this.getSlackNotificationTarget(entityRef);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to get Slack channel for entity: ${
|
||||
(error as Error).message
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!channel) {
|
||||
this.logger.debug(`No Slack channel found for entity: ${entityRef}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Sending notification with payload: ${JSON.stringify(
|
||||
options.payload,
|
||||
)}`,
|
||||
);
|
||||
|
||||
const payload = toChatPostMessageArgs({
|
||||
channel,
|
||||
payload: options.payload,
|
||||
});
|
||||
|
||||
this.logger.debug(
|
||||
`Sending Slack channel notification: ${JSON.stringify(payload)}`,
|
||||
);
|
||||
outbound.push(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
console.log('dispatching message');
|
||||
await this.sendNotifications(outbound);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async postProcess(
|
||||
notification: Notification,
|
||||
options: NotificationSendOptions,
|
||||
): Promise<void> {
|
||||
const destinations: string[] = [];
|
||||
|
||||
// Handle broadcast case
|
||||
if (notification.user === null) {
|
||||
destinations.push(...(this.broadcastChannels ?? []));
|
||||
} 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 destination = await this.getSlackNotificationTarget(
|
||||
notification.user,
|
||||
);
|
||||
|
||||
if (!destination) {
|
||||
this.logger.error(
|
||||
`No slack.com/bot-notify annotation found for user: ${notification.user}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
destinations.push(destination);
|
||||
}
|
||||
|
||||
// If no destinations, nothing to do
|
||||
if (destinations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare outbound messages
|
||||
const outbound = destinations.map(channel =>
|
||||
toChatPostMessageArgs({ channel, payload: options.payload }),
|
||||
);
|
||||
|
||||
// Log debug info
|
||||
outbound.forEach(payload => {
|
||||
this.logger.debug(`Sending notification: ${JSON.stringify(payload)}`);
|
||||
});
|
||||
|
||||
// Send notifications
|
||||
await this.sendNotifications(outbound);
|
||||
}
|
||||
|
||||
async getEntities(
|
||||
entityRefs: readonly string[],
|
||||
): Promise<(Entity | undefined)[]> {
|
||||
const { token } = await this.auth.getPluginRequestToken({
|
||||
onBehalfOf: await this.auth.getOwnServiceCredentials(),
|
||||
targetPluginId: 'catalog',
|
||||
});
|
||||
|
||||
const response = await this.catalog.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: entityRefs.slice(),
|
||||
fields: [`metadata.annotations.${ANNOTATION_SLACK_BOT_NOTIFY}`],
|
||||
},
|
||||
{
|
||||
token,
|
||||
},
|
||||
);
|
||||
|
||||
return response.items;
|
||||
}
|
||||
|
||||
async getSlackNotificationTarget(
|
||||
entityRef: string,
|
||||
): Promise<string | undefined> {
|
||||
const entityLoader = new DataLoader<string, Entity | undefined>(
|
||||
entityRefs => this.getEntities(entityRefs),
|
||||
);
|
||||
const entity = await entityLoader.load(entityRef);
|
||||
|
||||
if (!entity) {
|
||||
console.log(`Entity not found: ${entityRef}`);
|
||||
throw new NotFoundError(`Entity not found: ${entityRef}`);
|
||||
}
|
||||
|
||||
return entity?.metadata?.annotations?.[ANNOTATION_SLACK_BOT_NOTIFY];
|
||||
}
|
||||
|
||||
async sendNotification(args: ChatPostMessageArguments): Promise<void> {
|
||||
const response = await this.slack.chat.postMessage(args);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to send notification: ${response.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @public
|
||||
* The annotation key for the entity's Slack ID. This can be
|
||||
* any valid chat.postMessage destination including:
|
||||
* - A user ID (U12345678)
|
||||
* - A channel ID (C12345678)
|
||||
* - A DM ID (D12345678)
|
||||
* - A group ID (S12345678)
|
||||
*
|
||||
* It can also be a user's email address or a channel name,
|
||||
* however IDs are preferred.
|
||||
*/
|
||||
export const ANNOTATION_SLACK_BOT_NOTIFY = 'slack.com/bot-notify';
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { SlackNotificationProcessor } from './SlackNotificationProcessor';
|
||||
export * from './constants';
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export interface SlackNotificationOptions {
|
||||
url: string;
|
||||
payload: string;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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 {
|
||||
NotificationPayload,
|
||||
NotificationSeverity,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
import { ChatPostMessageArguments, KnownBlock } from '@slack/web-api';
|
||||
|
||||
export function toChatPostMessageArgs(options: {
|
||||
channel: string;
|
||||
payload: NotificationPayload;
|
||||
}): ChatPostMessageArguments {
|
||||
const { channel, payload } = options;
|
||||
|
||||
const args: ChatPostMessageArguments = {
|
||||
channel,
|
||||
text: payload.title,
|
||||
attachments: [
|
||||
{
|
||||
color: getColor(payload.severity),
|
||||
blocks: toSlackBlockKit(payload),
|
||||
fallback: payload.title,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function toSlackBlockKit(payload: NotificationPayload): KnownBlock[] {
|
||||
const { description, link, severity, topic } = payload;
|
||||
return [
|
||||
{
|
||||
type: 'section',
|
||||
...(description && {
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: description ?? 'No description provided',
|
||||
},
|
||||
}),
|
||||
accessory: {
|
||||
type: 'button',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: 'View More',
|
||||
},
|
||||
...(link && { url: link }),
|
||||
action_id: 'button-action',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'context',
|
||||
elements: [
|
||||
{
|
||||
type: 'plain_text',
|
||||
text: `Severity: ${severity ?? 'normal'}`,
|
||||
emoji: true,
|
||||
},
|
||||
{
|
||||
type: 'plain_text',
|
||||
text: `Topic: ${topic ?? 'N/A'}`,
|
||||
emoji: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getColor(severity: NotificationSeverity | undefined) {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return '#FF0000'; // Red
|
||||
case 'high':
|
||||
return '#FFA500'; // Orange
|
||||
case 'low':
|
||||
case 'normal':
|
||||
default:
|
||||
return '#00A699'; // Neutral color
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 {
|
||||
coreServices,
|
||||
createBackendModule,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { CatalogClient } from '@backstage/catalog-client';
|
||||
import { notificationsProcessingExtensionPoint } from '@backstage/plugin-notifications-node';
|
||||
import { SlackNotificationProcessor } from './lib/SlackNotificationProcessor';
|
||||
|
||||
/**
|
||||
* The Slack notification processor for use with the notifications plugin.
|
||||
* This allows sending of notifications via Slack DMs or to channels.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const notificationsModuleSlack = createBackendModule({
|
||||
pluginId: 'notifications',
|
||||
moduleId: 'slack',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: {
|
||||
auth: coreServices.auth,
|
||||
config: coreServices.rootConfig,
|
||||
discovery: coreServices.discovery,
|
||||
logger: coreServices.logger,
|
||||
notifications: notificationsProcessingExtensionPoint,
|
||||
},
|
||||
async init({ auth, config, discovery, logger, notifications }) {
|
||||
const catalogClient = new CatalogClient({
|
||||
discoveryApi: discovery,
|
||||
});
|
||||
|
||||
notifications.addProcessor(
|
||||
SlackNotificationProcessor.fromConfig(config, {
|
||||
auth,
|
||||
discovery,
|
||||
logger,
|
||||
catalog: catalogClient,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user