diff --git a/.changeset/light-cameras-fold.md b/.changeset/light-cameras-fold.md new file mode 100644 index 0000000000..8ec49c5d02 --- /dev/null +++ b/.changeset/light-cameras-fold.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-notifications-backend-module-slack': minor +--- + +Added a new Slack NotificationProcessor for use with the notifications plugin diff --git a/docs/notifications/processors.md b/docs/notifications/processors.md index abee7c97c3..2d7de61ba0 100644 --- a/docs/notifications/processors.md +++ b/docs/notifications/processors.md @@ -104,4 +104,32 @@ Apart from STMP, the email processor also supports the following transmissions: - sendmail - stream (only for debugging purposes) -See more information at https://github.com/backstage/backstage/blob/master/plugins/notifications-backend-module-email/README.md +See more information at + +### Slack Processor + +Slack processor is used to send notifications to users and channels in Slack. To install the Slack processor, add the `@backstage/plugin-notifications-backend-module-slack` package to your backend. + +```bash +yarn workspace backend add @backstage/plugin-notifications-backend-module-slack +``` + +Add the Slack processor to your backend: + +```ts +import { createBackend } from '@backstage/plugin-notifications-backend'; +const backend = createBackend(); +// ... +backend.add(import('@backstage/plugin-notifications-backend-module-slack')); +``` + +To configure the Slack processor, you need to add the following configuration to your `app-config.yaml`: + +```yaml +notifications: + processors: + slack: + - token: xoxb-XXXXXXXXX +``` + +See more information including how to configure your Slack App at diff --git a/plugins/notifications-backend-module-slack/.eslintrc.js b/plugins/notifications-backend-module-slack/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/notifications-backend-module-slack/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/notifications-backend-module-slack/README.md b/plugins/notifications-backend-module-slack/README.md new file mode 100644 index 0000000000..e64bac868c --- /dev/null +++ b/plugins/notifications-backend-module-slack/README.md @@ -0,0 +1,56 @@ +# @backstage/plugin-notifications-backend-module-slack + +The Slack backend module for the notifications plugin. + +## Getting Started + +### Module Installation + +Add the module to your backend: + +```ts +// packages/backend/src/index.ts +backend.add(import('@backstage/plugin-notifications-backend-module-slack')); +``` + +### Slack Configuration + +To use this you'll need to create a Slack App or use an existing one. It should have at least the following scopes: +`chat:write`, `users:read`, `im:write` (for direct message support). + +Additionally you may include scopes `chat:write.public` in order to send messages to public channels your app is not +a member of. + +These scopes are under OAuth & Permissions. You will also want to save the Bot User OAuth Token. This will be needed +in the following step to configure `app-config.yaml`. + +### Configure Backstage + +You'll now need to configure the Slack module in your `app-config.yaml`. + +```yaml +notifications: + processors: + slack: + - token: xoxb-XXXXXXXXX +``` + +Multiple instances can be added in the `slack` array, allowing you to have multiple configurations if you need to send +messages to more than one Slack workspace. Org-Wide App installation is not currently supported. + +### Entity Requirements + +Entities must be annotated with one of the following annotations: + +- `slack.com/user-id` for direct messages +- `slack.com/channel-name` for public channel messages +- `slack.com/channel-id` for public or private channel messages + +Slack prefers use of ID over name and `slack.com/channel-id` is the recommended annotation. + +### Observability + +The processor includes the following counter metrics if you are exporting metrics using OpenTelemetry: + +- `notifications.processors.slack.sent.count` - The number of messages sent +- `notifications.processors.slack.error.count` - The number of messages that failed to send diff --git a/plugins/notifications-backend-module-slack/catalog-info.yaml b/plugins/notifications-backend-module-slack/catalog-info.yaml new file mode 100644 index 0000000000..327ae8239f --- /dev/null +++ b/plugins/notifications-backend-module-slack/catalog-info.yaml @@ -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 diff --git a/plugins/notifications-backend-module-slack/config.d.ts b/plugins/notifications-backend-module-slack/config.d.ts new file mode 100644 index 0000000000..f620f0e206 --- /dev/null +++ b/plugins/notifications-backend-module-slack/config.d.ts @@ -0,0 +1,28 @@ +/* + * 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; + }>; + }; + }; +} diff --git a/plugins/notifications-backend-module-slack/package.json b/plugins/notifications-backend-module-slack/package.json new file mode 100644 index 0000000000..2bc7a8eb00 --- /dev/null +++ b/plugins/notifications-backend-module-slack/package.json @@ -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.2.2", + "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.2.14" + }, + "configSchema": "config.d.ts" +} diff --git a/plugins/notifications-backend-module-slack/report.api.md b/plugins/notifications-backend-module-slack/report.api.md new file mode 100644 index 0000000000..dfd2460e91 --- /dev/null +++ b/plugins/notifications-backend-module-slack/report.api.md @@ -0,0 +1,20 @@ +## 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_CHANNEL_ID = 'slack.com/channel-id'; + +// @public +export const ANNOTATION_SLACK_CHANNEL_NAME = 'slack.com/channel-name'; + +// @public +export const ANNOTATION_SLACK_USER_ID = 'slack.com/user-id'; + +// @public +const notificationsModuleSlack: BackendFeature; +export default notificationsModuleSlack; +``` diff --git a/plugins/notifications-backend-module-slack/src/index.ts b/plugins/notifications-backend-module-slack/src/index.ts new file mode 100644 index 0000000000..14cde37d13 --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/index.ts @@ -0,0 +1,28 @@ +/* + * 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_CHANNEL_ID, + ANNOTATION_SLACK_CHANNEL_NAME, + ANNOTATION_SLACK_USER_ID, +} from './lib'; +export { notificationsModuleSlack as default } from './module'; diff --git a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts new file mode 100644 index 0000000000..5396a71736 --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts @@ -0,0 +1,285 @@ +/* + * 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/user-id': 'U12345678', + }, + }, + spec: { + type: 'service', + owner: 'group:default/mock', + }, + } as unknown as Entity, + { + kind: 'Group', + metadata: { + name: 'mock', + namespace: 'default', + annotations: { + 'slack.com/channel-id': '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', + text: { + type: 'mrkdwn', + text: 'No description provided', + }, + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'View More', + }, + url: '', + 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 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', + text: { + type: 'mrkdwn', + text: 'No description provided', + }, + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'View More', + }, + url: '', + 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 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]; + + processor.processOptions({ + recipients: { type: 'broadcast' }, + payload: { title: 'notification' }, + }); + 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(); + }); +}); diff --git a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts new file mode 100644 index 0000000000..225f2f20ae --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts @@ -0,0 +1,285 @@ +/* + * 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_CHANNEL_ID, + ANNOTATION_SLACK_CHANNEL_NAME, + ANNOTATION_SLACK_USER_ID, +} 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; + + static fromConfig( + config: Config, + options: { + auth: AuthService; + discovery: DiscoveryService; + logger: LoggerService; + catalog: CatalogApi; + slack?: WebClient; + }, + ): SlackNotificationProcessor[] { + const slackConfig = + config.getOptionalConfigArray('notifications.processors.slack') ?? []; + return slackConfig.map(c => { + const token = c.getString('token'); + const slack = options.slack ?? new WebClient(token); + return new SlackNotificationProcessor({ + slack, + ...options, + }); + }); + } + + private constructor(options: { + slack: WebClient; + auth: AuthService; + discovery: DiscoveryService; + logger: LoggerService; + catalog: CatalogApi; + }) { + const { auth, catalog, logger, slack } = options; + this.logger = logger; + this.catalog = catalog; + this.auth = auth; + this.slack = slack; + + 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 { + 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, but allow all other entity kinds + // to have a channel id annotation. + if (compoundEntityRef.kind === 'user') { + return; + } + + let channel; + try { + channel = await this.getChannelId(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 { + if (options.recipients.type === 'broadcast' || !notification.user) { + return; + } + + 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.getSlackUserId(notification.user); + if (!destination) { + this.logger.error(`No email found for user entity: ${notification.user}`); + return; + } + + const payload = toChatPostMessageArgs({ + channel: destination, + payload: options.payload, + }); + + this.logger.debug(`Sending DM notification: ${JSON.stringify(payload)}`); + + // batch it up + await this.sendNotifications([payload]); + } + + 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_CHANNEL_NAME}`, + `metadata.annotations.${ANNOTATION_SLACK_CHANNEL_ID}`, + `metadata.annotations.${ANNOTATION_SLACK_USER_ID}`, + ], + }, + { + token, + }, + ); + + return response.items; + } + + async getSlackUserId(entityRef: string): Promise { + const entityLoader = new DataLoader( + entityRefs => this.getEntities(entityRefs), + ); + const entity = await entityLoader.load(entityRef); + + return entity?.metadata?.annotations?.[ANNOTATION_SLACK_USER_ID]; + } + + async getChannelId(entityRef: string): Promise { + const entityLoader = new DataLoader( + 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_CHANNEL_ID] || + entity?.metadata?.annotations?.[ANNOTATION_SLACK_CHANNEL_NAME] + ); + } + + async sendNotification(args: ChatPostMessageArguments): Promise { + const response = await this.slack.chat.postMessage(args); + + if (!response.ok) { + throw new Error(`Failed to send notification: ${response.error}`); + } + } +} diff --git a/plugins/notifications-backend-module-slack/src/lib/constants.ts b/plugins/notifications-backend-module-slack/src/lib/constants.ts new file mode 100644 index 0000000000..24c1868d55 --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/lib/constants.ts @@ -0,0 +1,33 @@ +/* + * 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 user ID + */ +export const ANNOTATION_SLACK_USER_ID = 'slack.com/user-id'; + +/** + * @public + * The annotation key for the entity's Slack channel name. + */ +export const ANNOTATION_SLACK_CHANNEL_NAME = 'slack.com/channel-name'; + +/** + * @public + * The annotation key for the entity's Slack channel ID. + */ +export const ANNOTATION_SLACK_CHANNEL_ID = 'slack.com/channel-id'; diff --git a/plugins/notifications-backend-module-slack/src/lib/index.ts b/plugins/notifications-backend-module-slack/src/lib/index.ts new file mode 100644 index 0000000000..97835063b3 --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/lib/index.ts @@ -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'; diff --git a/plugins/notifications-backend-module-slack/src/lib/types.ts b/plugins/notifications-backend-module-slack/src/lib/types.ts new file mode 100644 index 0000000000..bd87e9656c --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/lib/types.ts @@ -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; +} diff --git a/plugins/notifications-backend-module-slack/src/lib/util.ts b/plugins/notifications-backend-module-slack/src/lib/util.ts new file mode 100644 index 0000000000..e39701d88a --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/lib/util.ts @@ -0,0 +1,92 @@ +/* + * 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', + text: { + type: 'mrkdwn', + text: description ?? 'No description provided', + }, + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'View More', + }, + 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 + } +} diff --git a/plugins/notifications-backend-module-slack/src/module.ts b/plugins/notifications-backend-module-slack/src/module.ts new file mode 100644 index 0000000000..cb096dabba --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/module.ts @@ -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, + }), + ); + }, + }); + }, +}); diff --git a/yarn.lock b/yarn.lock index 648c69d5cc..0881f8546f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6779,6 +6779,33 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-notifications-backend-module-slack@workspace:plugins/notifications-backend-module-slack": + version: 0.0.0-use.local + resolution: "@backstage/plugin-notifications-backend-module-slack@workspace:plugins/notifications-backend-module-slack" + dependencies: + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/catalog-client": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-catalog-node": "workspace:^" + "@backstage/plugin-notifications-common": "workspace:^" + "@backstage/plugin-notifications-node": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/types": "workspace:^" + "@faker-js/faker": ^8.4.1 + "@opentelemetry/api": ^1.9.0 + "@slack/bolt": ^3.21.4 + "@slack/types": ^2.14.0 + "@slack/web-api": ^7.5.0 + dataloader: 2.2.2 + msw: ^2.2.14 + p-throttle: 4.1.1 + languageName: unknown + linkType: soft + "@backstage/plugin-notifications-backend@workspace:^, @backstage/plugin-notifications-backend@workspace:plugins/notifications-backend": version: 0.0.0-use.local resolution: "@backstage/plugin-notifications-backend@workspace:plugins/notifications-backend" @@ -9644,6 +9671,13 @@ __metadata: languageName: node linkType: hard +"@faker-js/faker@npm:^8.4.1": + version: 8.4.1 + resolution: "@faker-js/faker@npm:8.4.1" + checksum: d802d531f8929562715adc279cfec763c9a4bc596ec67b0ce43fd0ae61b285d2b0eec6f1f4aa852452a63721a842fe7e81926dce7bd92acca94b01e2a1f55f5a + languageName: node + linkType: hard + "@fastify/busboy@npm:^2.0.0": version: 2.1.1 resolution: "@fastify/busboy@npm:2.1.1" @@ -16403,6 +16437,121 @@ __metadata: languageName: node linkType: hard +"@slack/bolt@npm:^3.21.4": + version: 3.22.0 + resolution: "@slack/bolt@npm:3.22.0" + dependencies: + "@slack/logger": ^4.0.0 + "@slack/oauth": ^2.6.3 + "@slack/socket-mode": ^1.3.6 + "@slack/types": ^2.13.0 + "@slack/web-api": ^6.13.0 + "@types/express": ^4.16.1 + "@types/promise.allsettled": ^1.0.3 + "@types/tsscmp": ^1.0.0 + axios: ^1.7.4 + express: ^4.21.0 + path-to-regexp: ^8.1.0 + promise.allsettled: ^1.0.2 + raw-body: ^2.3.3 + tsscmp: ^1.0.6 + checksum: edd5c7cf658808effde87c936f19a0cc2b7d49ac97471651f2b1bb3db0074b92dc8ad3c9657577105d93c48df9ba16c382902c0d90082854cbbe86bfc7753827 + languageName: node + linkType: hard + +"@slack/logger@npm:^3.0.0": + version: 3.0.0 + resolution: "@slack/logger@npm:3.0.0" + dependencies: + "@types/node": ">=12.0.0" + checksum: 6512d0e9e4be47ea465705ab9b6e6901f36fa981da0d4a657fde649d452b567b351002049b5ee0a22569b5119bf6c2f61befd5b8022d878addb7a99c91b03389 + languageName: node + linkType: hard + +"@slack/logger@npm:^4.0.0": + version: 4.0.0 + resolution: "@slack/logger@npm:4.0.0" + dependencies: + "@types/node": ">=18.0.0" + checksum: dc79e9d2032c4bf9ce01d96cc72882f003dd376d036f172d4169662cfc2c9b384a80d5546b06021578dd473e7059f064303f0ba851eeb153387f2081a1e3062e + languageName: node + linkType: hard + +"@slack/oauth@npm:^2.6.3": + version: 2.6.3 + resolution: "@slack/oauth@npm:2.6.3" + dependencies: + "@slack/logger": ^3.0.0 + "@slack/web-api": ^6.12.1 + "@types/jsonwebtoken": ^8.3.7 + "@types/node": ">=12" + jsonwebtoken: ^9.0.0 + lodash.isstring: ^4.0.1 + checksum: 6b556da01bd2b026177b4074cd44bdeff00165fb4297ef8f350035ca79ababfff0c0993a297a46ab742bb97469c6c1c8f5790c328ecf6370290fe31014ba3c5e + languageName: node + linkType: hard + +"@slack/socket-mode@npm:^1.3.6": + version: 1.3.6 + resolution: "@slack/socket-mode@npm:1.3.6" + dependencies: + "@slack/logger": ^3.0.0 + "@slack/web-api": ^6.12.1 + "@types/node": ">=12.0.0" + "@types/ws": ^7.4.7 + eventemitter3: ^5 + finity: ^0.5.4 + ws: ^7.5.3 + checksum: a84c15a6d25a21f76258d1ccebeec1d78b0a0dac0b02ffdfcb3596e7acda5459e4b99a42207eab7e57bed7a2a1d85ac173adf5e07aa66949eac9cc9df3b43947 + languageName: node + linkType: hard + +"@slack/types@npm:^2.11.0, @slack/types@npm:^2.13.0, @slack/types@npm:^2.14.0, @slack/types@npm:^2.9.0": + version: 2.14.0 + resolution: "@slack/types@npm:2.14.0" + checksum: fbef74d50d0de8f16125f7178bd2e664a69eeefd827b09c6f78153957278fc4400049685c756076e7dbcabd04c22730ac783bcc5d36fd588c7749d35f02c2afd + languageName: node + linkType: hard + +"@slack/web-api@npm:^6.12.1, @slack/web-api@npm:^6.13.0": + version: 6.13.0 + resolution: "@slack/web-api@npm:6.13.0" + dependencies: + "@slack/logger": ^3.0.0 + "@slack/types": ^2.11.0 + "@types/is-stream": ^1.1.0 + "@types/node": ">=12.0.0" + axios: ^1.7.4 + eventemitter3: ^3.1.0 + form-data: ^2.5.0 + is-electron: 2.2.2 + is-stream: ^1.1.0 + p-queue: ^6.6.1 + p-retry: ^4.0.0 + checksum: 77f0d506bbb011ae43d322e5152e8b1ec2b88aa01256da6b3c9ff8ce106d2284f887cad2d9f044e0fe34dc865d60f2bce1c6bb5c4117150ff71a7ef341f5dfeb + languageName: node + linkType: hard + +"@slack/web-api@npm:^7.5.0": + version: 7.8.0 + resolution: "@slack/web-api@npm:7.8.0" + dependencies: + "@slack/logger": ^4.0.0 + "@slack/types": ^2.9.0 + "@types/node": ">=18.0.0" + "@types/retry": 0.12.0 + axios: ^1.7.8 + eventemitter3: ^5.0.1 + form-data: ^4.0.0 + is-electron: 2.2.2 + is-stream: ^2 + p-queue: ^6 + p-retry: ^4 + retry: ^0.13.1 + checksum: d76fcb6cfe8a8ebdaf71aaee7dfef54f3ce4fb5958f9f841bcba095097349f649c47e5e79adbaabbd4051fe0c8d6d96445adeeab3d67416c161ae20868f637f0 + languageName: node + linkType: hard + "@smithy/abort-controller@npm:^3.1.2, @smithy/abort-controller@npm:^3.1.9": version: 3.1.9 resolution: "@smithy/abort-controller@npm:3.1.9" @@ -19452,7 +19601,7 @@ __metadata: languageName: node linkType: hard -"@types/express@npm:*, @types/express@npm:^4.17.21, @types/express@npm:^4.17.6": +"@types/express@npm:*, @types/express@npm:^4.16.1, @types/express@npm:^4.17.21, @types/express@npm:^4.17.6": version: 4.17.21 resolution: "@types/express@npm:4.17.21" dependencies: @@ -19614,6 +19763,15 @@ __metadata: languageName: node linkType: hard +"@types/is-stream@npm:^1.1.0": + version: 1.1.0 + resolution: "@types/is-stream@npm:1.1.0" + dependencies: + "@types/node": "*" + checksum: 23fcb06cd8adc0124d4c44071bd4b447c41f5e4c2eccb6166789c7fc0992b566e2e8b628a3800ff4472b686d9085adbec203925068bf72e350e085650e83adec + languageName: node + linkType: hard + "@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1": version: 2.0.1 resolution: "@types/istanbul-lib-coverage@npm:2.0.1" @@ -19732,6 +19890,15 @@ __metadata: languageName: node linkType: hard +"@types/jsonwebtoken@npm:^8.3.7": + version: 8.5.9 + resolution: "@types/jsonwebtoken@npm:8.5.9" + dependencies: + "@types/node": "*" + checksum: 33815ab02d1371b423118316b7706d2f2ec03eeee5e1494be72da50425d2384e5e0a09ea193f7a5ab4b4f6a9c5847147305f50e965f3d927a95bdf8adb471b2a + languageName: node + linkType: hard + "@types/jsonwebtoken@npm:^9.0.0": version: 9.0.0 resolution: "@types/jsonwebtoken@npm:9.0.0" @@ -19940,7 +20107,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=13.7.0, @types/node@npm:^22.0.0": +"@types/node@npm:*, @types/node@npm:>=12, @types/node@npm:>=12.0.0, @types/node@npm:>=13.7.0, @types/node@npm:>=18.0.0, @types/node@npm:^22.0.0": version: 22.13.10 resolution: "@types/node@npm:22.13.10" dependencies: @@ -20167,6 +20334,13 @@ __metadata: languageName: node linkType: hard +"@types/promise.allsettled@npm:^1.0.3": + version: 1.0.6 + resolution: "@types/promise.allsettled@npm:1.0.6" + checksum: 07dca8da25b49c0dc323201095552d86159483dc910dc61c345357c9c196b8498e6be4bf260cc2a9a539a725108df61b53db1d82723ed9886bb7c72fedd65f14 + languageName: node + linkType: hard + "@types/prop-types@npm:*, @types/prop-types@npm:^15.0.0, @types/prop-types@npm:^15.7.12, @types/prop-types@npm:^15.7.3": version: 15.7.14 resolution: "@types/prop-types@npm:15.7.14" @@ -20371,6 +20545,13 @@ __metadata: languageName: node linkType: hard +"@types/retry@npm:0.12.0": + version: 0.12.0 + resolution: "@types/retry@npm:0.12.0" + checksum: 61a072c7639f6e8126588bf1eb1ce8835f2cb9c2aba795c4491cf6310e013267b0c8488039857c261c387e9728c1b43205099223f160bb6a76b4374f741b5603 + languageName: node + linkType: hard + "@types/retry@npm:0.12.2": version: 0.12.2 resolution: "@types/retry@npm:0.12.2" @@ -20666,6 +20847,13 @@ __metadata: languageName: node linkType: hard +"@types/tsscmp@npm:^1.0.0": + version: 1.0.2 + resolution: "@types/tsscmp@npm:1.0.2" + checksum: c02c0bb9f14f550947fea9fa6f9f3c28e6b2d47a6d049a5450ed466fb0c8a685b6ff37d070d4c43d930a5affc9d828f5e16e35cde1e734de228ffd2df76ac2a8 + languageName: node + linkType: hard + "@types/unist@npm:*, @types/unist@npm:^2.0.0": version: 2.0.6 resolution: "@types/unist@npm:2.0.6" @@ -20742,6 +20930,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:^7.4.7": + version: 7.4.7 + resolution: "@types/ws@npm:7.4.7" + dependencies: + "@types/node": "*" + checksum: b4c9b8ad209620c9b21e78314ce4ff07515c0cadab9af101c1651e7bfb992d7fd933bd8b9c99d110738fd6db523ed15f82f29f50b45510288da72e964dedb1a3 + languageName: node + linkType: hard + "@types/xml-encryption@npm:^1.2.4": version: 1.2.4 resolution: "@types/xml-encryption@npm:1.2.4" @@ -22947,6 +23144,21 @@ __metadata: languageName: node linkType: hard +"array.prototype.map@npm:^1.0.5": + version: 1.0.8 + resolution: "array.prototype.map@npm:1.0.8" + dependencies: + call-bind: ^1.0.8 + call-bound: ^1.0.3 + define-properties: ^1.2.1 + es-abstract: ^1.23.6 + es-array-method-boxes-properly: ^1.0.0 + es-object-atoms: ^1.0.0 + is-string: ^1.1.1 + checksum: df321613636ec8461965d72421569ece78f269460535ced5ec88db9aaa4fc58a9f26e597d72e726f105c55fa4b4b6db0d3156489dc13dfbc7a098b4f1d17b5ab + languageName: node + linkType: hard + "array.prototype.tosorted@npm:^1.1.4": version: 1.1.4 resolution: "array.prototype.tosorted@npm:1.1.4" @@ -23301,7 +23513,7 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.0.0, axios@npm:^1.6.0, axios@npm:^1.7.4, axios@npm:^1.7.7": +"axios@npm:^1.0.0, axios@npm:^1.6.0, axios@npm:^1.7.4, axios@npm:^1.7.7, axios@npm:^1.7.8": version: 1.8.4 resolution: "axios@npm:1.8.4" dependencies: @@ -24345,13 +24557,13 @@ __metadata: languageName: node linkType: hard -"call-bind-apply-helpers@npm:^1.0.0, call-bind-apply-helpers@npm:^1.0.1": - version: 1.0.1 - resolution: "call-bind-apply-helpers@npm:1.0.1" +"call-bind-apply-helpers@npm:^1.0.0, call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" dependencies: es-errors: ^1.3.0 function-bind: ^1.1.2 - checksum: 3c55343261bb387c58a4762d15ad9d42053659a62681ec5eb50690c6b52a4a666302a01d557133ce6533e8bd04530ee3b209f23dd06c9577a1925556f8fcccdf + checksum: b2863d74fcf2a6948221f65d95b91b4b2d90cfe8927650b506141e669f7d5de65cea191bf788838bc40d13846b7886c5bc5c84ab96c3adbcf88ad69a72fcdc6b languageName: node linkType: hard @@ -26420,6 +26632,13 @@ __metadata: languageName: node linkType: hard +"dataloader@npm:2.2.2": + version: 2.2.2 + resolution: "dataloader@npm:2.2.2" + checksum: 4dabd247089c29f194e94d5434d504f99156c5c214a03463c20f3f17f40398d7e179edee69a27c16e315519ac8739042a810090087ae26449a0e685156a02c65 + languageName: node + linkType: hard + "dataloader@npm:^2.0.0, dataloader@npm:^2.2.2": version: 2.2.3 resolution: "dataloader@npm:2.2.3" @@ -27649,6 +27868,13 @@ __metadata: languageName: node linkType: hard +"es-array-method-boxes-properly@npm:^1.0.0": + version: 1.0.0 + resolution: "es-array-method-boxes-properly@npm:1.0.0" + checksum: 2537fcd1cecf187083890bc6f5236d3a26bf39237433587e5bf63392e88faae929dbba78ff0120681a3f6f81c23fe3816122982c160d63b38c95c830b633b826 + languageName: node + linkType: hard + "es-define-property@npm:^1.0.0, es-define-property@npm:^1.0.1": version: 1.0.1 resolution: "es-define-property@npm:1.0.1" @@ -27663,6 +27889,23 @@ __metadata: languageName: node linkType: hard +"es-get-iterator@npm:^1.0.2": + version: 1.1.3 + resolution: "es-get-iterator@npm:1.1.3" + dependencies: + call-bind: ^1.0.2 + get-intrinsic: ^1.1.3 + has-symbols: ^1.0.3 + is-arguments: ^1.1.1 + is-map: ^2.0.2 + is-set: ^2.0.2 + is-string: ^1.0.7 + isarray: ^2.0.5 + stop-iteration-iterator: ^1.0.0 + checksum: 8fa118da42667a01a7c7529f8a8cca514feeff243feec1ce0bb73baaa3514560bd09d2b3438873cf8a5aaec5d52da248131de153b28e2638a061b6e4df13267d + languageName: node + linkType: hard + "es-iterator-helpers@npm:^1.2.1": version: 1.2.1 resolution: "es-iterator-helpers@npm:1.2.1" @@ -27694,12 +27937,12 @@ __metadata: languageName: node linkType: hard -"es-object-atoms@npm:^1.0.0": - version: 1.0.0 - resolution: "es-object-atoms@npm:1.0.0" +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": + version: 1.1.1 + resolution: "es-object-atoms@npm:1.1.1" dependencies: es-errors: ^1.3.0 - checksum: 26f0ff78ab93b63394e8403c353842b2272836968de4eafe97656adfb8a7c84b9099bf0fe96ed58f4a4cddc860f6e34c77f91649a58a5daa4a9c40b902744e3c + checksum: 214d3767287b12f36d3d7267ef342bbbe1e89f899cfd67040309fc65032372a8e60201410a99a1645f2f90c1912c8c49c8668066f6bdd954bcd614dda2e3da97 languageName: node linkType: hard @@ -28540,7 +28783,7 @@ __metadata: languageName: node linkType: hard -"eventemitter3@npm:^5.0.1": +"eventemitter3@npm:^5, eventemitter3@npm:^5.0.1": version: 5.0.1 resolution: "eventemitter3@npm:5.0.1" checksum: 543d6c858ab699303c3c32e0f0f47fc64d360bf73c3daf0ac0b5079710e340d6fe9f15487f94e66c629f5f82cd1a8678d692f3dbb6f6fcd1190e1b97fcad36f8 @@ -28985,7 +29228,7 @@ __metadata: languageName: node linkType: hard -"express@npm:^4.14.0, express@npm:^4.17.1, express@npm:^4.18.1, express@npm:^4.18.2, express@npm:^4.19.2, express@npm:^4.21.2": +"express@npm:^4.14.0, express@npm:^4.17.1, express@npm:^4.18.1, express@npm:^4.18.2, express@npm:^4.19.2, express@npm:^4.21.0, express@npm:^4.21.2": version: 4.21.2 resolution: "express@npm:4.21.2" dependencies: @@ -29546,6 +29789,13 @@ __metadata: languageName: node linkType: hard +"finity@npm:^0.5.4": + version: 0.5.4 + resolution: "finity@npm:0.5.4" + checksum: eeea74de356ba963231108c3f8e2de44b4114497389121d603f8c3e8316b8d0772ff06b731af08ef5d6ca6b0e3a0fffab452122eca48837a98a2f7e5548b6be2 + languageName: node + linkType: hard + "first-chunk-stream@npm:^2.0.0": version: 2.0.0 resolution: "first-chunk-stream@npm:2.0.0" @@ -30132,21 +30382,21 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6": - version: 1.2.6 - resolution: "get-intrinsic@npm:1.2.6" +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6": + version: 1.3.0 + resolution: "get-intrinsic@npm:1.3.0" dependencies: - call-bind-apply-helpers: ^1.0.1 - dunder-proto: ^1.0.0 + call-bind-apply-helpers: ^1.0.2 es-define-property: ^1.0.1 es-errors: ^1.3.0 - es-object-atoms: ^1.0.0 + es-object-atoms: ^1.1.1 function-bind: ^1.1.2 + get-proto: ^1.0.1 gopd: ^1.2.0 has-symbols: ^1.1.0 hasown: ^2.0.2 - math-intrinsics: ^1.0.0 - checksum: a7592a0b7f023a2e83c0121fa9449ca83780e370a5feeebe8452119474d148016e43b455049134ae7a683b9b11b93d3f65eac199a0ad452ab740d5f0c299de47 + math-intrinsics: ^1.1.0 + checksum: 301008e4482bb9a9cb49e132b88fee093bff373b4e6def8ba219b1e96b60158a6084f273ef5cafe832e42cd93462f4accb46a618d35fe59a2b507f2388c5b79d languageName: node linkType: hard @@ -30171,6 +30421,16 @@ __metadata: languageName: node linkType: hard +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: ^1.0.1 + es-object-atoms: ^1.0.0 + checksum: 4fc96afdb58ced9a67558698b91433e6b037aaa6f1493af77498d7c85b141382cf223c0e5946f334fb328ee85dfe6edd06d218eaf09556f4bc4ec6005d7f5f7b + languageName: node + linkType: hard + "get-stdin@npm:^9.0.0": version: 9.0.0 resolution: "get-stdin@npm:9.0.0" @@ -32025,13 +32285,13 @@ __metadata: languageName: node linkType: hard -"is-arguments@npm:^1.0.4": - version: 1.1.1 - resolution: "is-arguments@npm:1.1.1" +"is-arguments@npm:^1.0.4, is-arguments@npm:^1.1.1": + version: 1.2.0 + resolution: "is-arguments@npm:1.2.0" dependencies: - call-bind: ^1.0.2 - has-tostringtag: ^1.0.0 - checksum: 7f02700ec2171b691ef3e4d0e3e6c0ba408e8434368504bb593d0d7c891c0dbfda6d19d30808b904a6cb1929bca648c061ba438c39f296c2a8ca083229c49f27 + call-bound: ^1.0.2 + has-tostringtag: ^1.0.2 + checksum: aae9307fedfe2e5be14aebd0f48a9eeedf6b8c8f5a0b66257b965146d1e94abdc3f08e3dce3b1d908e1fa23c70039a88810ee1d753905758b9b6eebbab0bafeb languageName: node linkType: hard @@ -32193,6 +32453,13 @@ __metadata: languageName: node linkType: hard +"is-electron@npm:2.2.2": + version: 2.2.2 + resolution: "is-electron@npm:2.2.2" + checksum: de5aa8bd8d72c96675b8d0f93fab4cc21f62be5440f65bc05c61338ca27bd851a64200f31f1bf9facbaa01b3dbfed7997b2186741d84b93b63e0aff1db6a9494 + languageName: node + linkType: hard + "is-elevated@npm:^3.0.0": version: 3.0.0 resolution: "is-elevated@npm:3.0.0" @@ -32323,7 +32590,7 @@ __metadata: languageName: node linkType: hard -"is-map@npm:^2.0.3": +"is-map@npm:^2.0.2, is-map@npm:^2.0.3": version: 2.0.3 resolution: "is-map@npm:2.0.3" checksum: e6ce5f6380f32b141b3153e6ba9074892bbbbd655e92e7ba5ff195239777e767a976dcd4e22f864accaf30e53ebf961ab1995424aef91af68788f0591b7396cc @@ -32529,7 +32796,7 @@ __metadata: languageName: node linkType: hard -"is-set@npm:^2.0.3": +"is-set@npm:^2.0.2, is-set@npm:^2.0.3": version: 2.0.3 resolution: "is-set@npm:2.0.3" checksum: 36e3f8c44bdbe9496c9689762cc4110f6a6a12b767c5d74c0398176aa2678d4467e3bf07595556f2dba897751bde1422480212b97d973c7b08a343100b0c0dfe @@ -32561,7 +32828,7 @@ __metadata: languageName: node linkType: hard -"is-stream@npm:^2.0.0, is-stream@npm:^2.0.1": +"is-stream@npm:^2, is-stream@npm:^2.0.0, is-stream@npm:^2.0.1": version: 2.0.1 resolution: "is-stream@npm:2.0.1" checksum: b8e05ccdf96ac330ea83c12450304d4a591f9958c11fd17bed240af8d5ffe08aedafa4c0f4cfccd4d28dc9d4d129daca1023633d5c11601a6cbc77521f6fae66 @@ -32935,6 +33202,23 @@ __metadata: languageName: node linkType: hard +"iterate-iterator@npm:^1.0.1": + version: 1.0.2 + resolution: "iterate-iterator@npm:1.0.2" + checksum: 97b3ed4f2bebe038be57d03277879e406b2c537ceeeab7f82d4167f9a3cff872cc2cc5da3dc9920ff544ca247329d2a4d44121bb8ef8d0807a72176bdbc17c84 + languageName: node + linkType: hard + +"iterate-value@npm:^1.0.2": + version: 1.0.2 + resolution: "iterate-value@npm:1.0.2" + dependencies: + es-get-iterator: ^1.0.2 + iterate-iterator: ^1.0.1 + checksum: 446a4181657df1872e5020713206806757157db6ab375dee05eb4565b66e1244d7a99cd36ce06862261ad4bd059e66ba8192f62b5d1ff41d788c3b61953af6c3 + languageName: node + linkType: hard + "iterator.prototype@npm:^1.1.4": version: 1.1.4 resolution: "iterator.prototype@npm:1.1.4" @@ -35627,7 +35911,7 @@ __metadata: languageName: node linkType: hard -"math-intrinsics@npm:^1.0.0, math-intrinsics@npm:^1.1.0": +"math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" checksum: 0e513b29d120f478c85a70f49da0b8b19bc638975eca466f2eeae0071f3ad00454c621bf66e16dd435896c208e719fc91ad79bbfba4e400fe0b372e7c1c9c9a2 @@ -36973,7 +37257,7 @@ __metadata: languageName: node linkType: hard -"msw@npm:^2.0.0, msw@npm:^2.0.8": +"msw@npm:^2.0.0, msw@npm:^2.0.8, msw@npm:^2.2.14": version: 2.7.3 resolution: "msw@npm:2.7.3" dependencies: @@ -38458,7 +38742,7 @@ __metadata: languageName: node linkType: hard -"p-queue@npm:^6.6.2": +"p-queue@npm:^6, p-queue@npm:^6.6.1, p-queue@npm:^6.6.2": version: 6.6.2 resolution: "p-queue@npm:6.6.2" dependencies: @@ -38468,6 +38752,16 @@ __metadata: languageName: node linkType: hard +"p-retry@npm:^4, p-retry@npm:^4.0.0": + version: 4.6.2 + resolution: "p-retry@npm:4.6.2" + dependencies: + "@types/retry": 0.12.0 + retry: ^0.13.1 + checksum: 45c270bfddaffb4a895cea16cb760dcc72bdecb6cb45fef1971fa6ea2e91ddeafddefe01e444ac73e33b1b3d5d29fb0dd18a7effb294262437221ddc03ce0f2e + languageName: node + linkType: hard + "p-retry@npm:^6.2.0": version: 6.2.0 resolution: "p-retry@npm:6.2.0" @@ -38479,7 +38773,7 @@ __metadata: languageName: node linkType: hard -"p-throttle@npm:^4.1.1": +"p-throttle@npm:4.1.1, p-throttle@npm:^4.1.1": version: 4.1.1 resolution: "p-throttle@npm:4.1.1" checksum: fe8709f3c3b1da7c033479375c2c302e80c1a5d86449013afa7cd46d1dc210bc824a7e4a9d088e66d31987d00878c2b5491bb2fe76246d4d2fc9a1636f5f8298 @@ -40193,6 +40487,20 @@ __metadata: languageName: node linkType: hard +"promise.allsettled@npm:^1.0.2": + version: 1.0.7 + resolution: "promise.allsettled@npm:1.0.7" + dependencies: + array.prototype.map: ^1.0.5 + call-bind: ^1.0.2 + define-properties: ^1.2.0 + es-abstract: ^1.22.1 + get-intrinsic: ^1.2.1 + iterate-value: ^1.0.2 + checksum: 96186392286e5ab9aef1a1a725c061c8cf268b6cf141f151daa3834bb8e1680f3b159af6536ce59cf80d4a6a5ad1d8371d05759980cc6c90d58800ddb0a7c119 + languageName: node + linkType: hard + "promise.series@npm:^0.2.0": version: 0.2.0 resolution: "promise.series@npm:0.2.0" @@ -40613,7 +40921,7 @@ __metadata: languageName: node linkType: hard -"raw-body@npm:2.5.2, raw-body@npm:^2.4.1": +"raw-body@npm:2.5.2, raw-body@npm:^2.3.3, raw-body@npm:^2.4.1": version: 2.5.2 resolution: "raw-body@npm:2.5.2" dependencies: @@ -43876,6 +44184,16 @@ __metadata: languageName: node linkType: hard +"stop-iteration-iterator@npm:^1.0.0": + version: 1.1.0 + resolution: "stop-iteration-iterator@npm:1.1.0" + dependencies: + es-errors: ^1.3.0 + internal-slot: ^1.1.0 + checksum: be944489d8829fb3bdec1a1cc4a2142c6b6eb317305eeace1ece978d286d6997778afa1ae8cb3bd70e2b274b9aa8c69f93febb1e15b94b1359b11058f9d3c3a1 + languageName: node + linkType: hard + "stoppable@npm:^1.1.0": version: 1.1.0 resolution: "stoppable@npm:1.1.0" @@ -45518,7 +45836,7 @@ __metadata: languageName: node linkType: hard -"tsscmp@npm:1.0.6": +"tsscmp@npm:1.0.6, tsscmp@npm:^1.0.6": version: 1.0.6 resolution: "tsscmp@npm:1.0.6" checksum: 1512384def36bccc9125cabbd4c3b0e68608d7ee08127ceaa0b84a71797263f1a01c7f82fa69be8a3bd3c1396e2965d2f7b52d581d3a5eeaf3967fbc52e3b3bf @@ -47450,7 +47768,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^7, ws@npm:^7.5.5": +"ws@npm:^7, ws@npm:^7.5.3, ws@npm:^7.5.5": version: 7.5.10 resolution: "ws@npm:7.5.10" peerDependencies: