diff --git a/packages/backend/src/plugins/awards.ts b/packages/backend/src/plugins/awards.ts index eb2b61d..89e93e8 100644 --- a/packages/backend/src/plugins/awards.ts +++ b/packages/backend/src/plugins/awards.ts @@ -9,5 +9,8 @@ export default async function createPlugin( logger: env.logger, database: env.database, identity: env.identity, + config: env.config, + discovery: env.discovery, + tokenManager: env.tokenManager, }); } diff --git a/plugins/awards-backend/README.md b/plugins/awards-backend/README.md index 76402dd..022d384 100644 --- a/plugins/awards-backend/README.md +++ b/plugins/awards-backend/README.md @@ -58,6 +58,23 @@ function makeCreateEnv(config: Config) { } ``` +## Configuration + +### Slack notifications + +To enable Slack notifications, add the following to your `app-config.yaml` file: + +```yaml +awards: + notifications: + slack: + webhook: + # https://api.slack.com/messaging/webhooks + url: ${MY_SLACK_WEBHOOK_URL_ENV_VAR} +``` + +Users who have the `slack.com/user_id` annotation set (see [slack-catalog-backend](/plugins/slack-catalog-backend/README.md)) will be tagged in notifications that pertain to them. + ## Developing this plugin The plugin can be executed in isolation during development by running diff --git a/plugins/awards-backend/package.json b/plugins/awards-backend/package.json index fbeb44a..18de9ab 100644 --- a/plugins/awards-backend/package.json +++ b/plugins/awards-backend/package.json @@ -25,10 +25,13 @@ "dependencies": { "@backstage/backend-common": "^0.20.1", "@backstage/backend-plugin-api": "^0.6.9", + "@backstage/catalog-client": "^1.6.0", + "@backstage/catalog-model": "^1.4.4", "@backstage/config": "^1.1.1", "@backstage/errors": "^1.2.3", "@backstage/plugin-auth-node": "^0.4.3", "@seatgeek/backstage-plugin-awards-common": "link:*", + "@slack/webhook": "^7.0.2", "@types/express": "*", "express": "^4.17.1", "express-promise-router": "^4.1.0", diff --git a/plugins/awards-backend/src/awards.test.ts b/plugins/awards-backend/src/awards.test.ts new file mode 100644 index 0000000..c015afc --- /dev/null +++ b/plugins/awards-backend/src/awards.test.ts @@ -0,0 +1,112 @@ +/* + * Copyright SeatGeek + * Licensed under the terms of the Apache-2.0 license. See LICENSE file in project root for terms. + */ +import { Award } from '@seatgeek/backstage-plugin-awards-common'; +import * as winston from 'winston'; +import { Awards } from './awards'; +import { AwardsStore } from './database/awards'; +import { AwardsNotifier } from './notifier'; + +const frank = 'user:default/frank-ocean'; + +function makeAward(): Award { + return { + uid: '123456', + name: 'Test Award', + description: 'This is a test award', + image: 'image_data', + owners: [frank], + recipients: ['user:default/peyton-manning', 'user:default/serena-williams'], + }; +} + +describe('Awards', () => { + let db: jest.Mocked; + let notifier: jest.Mocked; + let awards: Awards; + + beforeEach(() => { + db = { + search: jest.fn(), + add: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + notifier = { + notifyNewRecipients: jest.fn(), + }; + const logger = winston.createLogger({ + transports: [new winston.transports.Console({ silent: true })], + }); + awards = new Awards(db, notifier, logger); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('create', () => { + it('should notify new recipients', async () => { + const award = makeAward(); + db.add = jest.fn().mockResolvedValue(award); + const result = await awards.create({ + name: award.name, + description: award.description, + image: award.image, + owners: award.owners, + recipients: award.recipients, + }); + + // wait for the afterCreate promises to complete + await new Promise(process.nextTick); + + expect(result).toEqual(award); + expect(db.add).toHaveBeenCalledWith( + award.name, + award.description, + award.image, + award.owners, + award.recipients, + ); + expect(notifier.notifyNewRecipients).toHaveBeenCalledWith(award, [ + 'user:default/peyton-manning', + 'user:default/serena-williams', + ]); + }); + }); + + describe('update', () => { + it('should notify new recipients', async () => { + const award = makeAward(); + db.search = jest.fn().mockResolvedValue([award]); + const updated = { + ...award, + recipients: [ + ...award.recipients, + 'user:default/megan-rapinoe', + 'user:default/adrianne-lenker', + ], + }; + db.update = jest.fn().mockResolvedValue(updated); + const result = await awards.update(frank, award.uid, updated); + + // wait for the afterUpdate promises to complete + await new Promise(process.nextTick); + + expect(result).toEqual(updated); + expect(db.update).toHaveBeenCalledWith( + updated.uid, + updated.name, + updated.description, + updated.image, + updated.owners, + updated.recipients, + ); + expect(notifier.notifyNewRecipients).toHaveBeenCalledWith(updated, [ + 'user:default/megan-rapinoe', + 'user:default/adrianne-lenker', + ]); + }); + }); +}); diff --git a/plugins/awards-backend/src/awards.ts b/plugins/awards-backend/src/awards.ts index 1da7f53..25d655a 100644 --- a/plugins/awards-backend/src/awards.ts +++ b/plugins/awards-backend/src/awards.ts @@ -6,13 +6,16 @@ import { NotFoundError } from '@backstage/errors'; import { Award, AwardInput } from '@seatgeek/backstage-plugin-awards-common'; import { Logger } from 'winston'; import { AwardsStore } from './database/awards'; +import { AwardsNotifier } from './notifier'; export class Awards { private readonly db: AwardsStore; private readonly logger: Logger; + private readonly notifier: AwardsNotifier; - constructor(db: AwardsStore, logger: Logger) { + constructor(db: AwardsStore, notifier: AwardsNotifier, logger: Logger) { this.db = db; + this.notifier = notifier; this.logger = logger.child({ class: 'Awards' }); this.logger.debug('Constructed'); } @@ -21,14 +24,36 @@ export class Awards { return await this.getAwardByUid(uid); } + private async afterCreate(award: Award): Promise { + if (award.recipients.length > 0) { + await this.notifier.notifyNewRecipients(award, award.recipients); + } + } + async create(input: AwardInput): Promise { - return await this.db.add( + const award = await this.db.add( input.name, input.description, input.image, input.owners, input.recipients, ); + + this.afterCreate(award).catch(e => { + this.logger.error('Error running afterCreate action', e); + }); + + return award; + } + + private async afterUpdate(curr: Award, previous: Award): Promise { + const newRecipients = curr.recipients.filter( + recipient => !previous.recipients.includes(recipient), + ); + + if (newRecipients.length > 0) { + await this.notifier.notifyNewRecipients(curr, newRecipients); + } } async update( @@ -51,6 +76,10 @@ export class Awards { input.recipients, ); + this.afterUpdate(updated, award).catch(e => { + this.logger.error('Error running afterUpdate action', e); + }); + return updated; } diff --git a/plugins/awards-backend/src/notifications/notifications.test.ts b/plugins/awards-backend/src/notifications/notifications.test.ts new file mode 100644 index 0000000..c55b65c --- /dev/null +++ b/plugins/awards-backend/src/notifications/notifications.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright SeatGeek + * Licensed under the terms of the Apache-2.0 license. See LICENSE file in project root for terms. + */ +import { UserEntity } from '@backstage/catalog-model'; +import { Award } from '@seatgeek/backstage-plugin-awards-common'; +import { IncomingWebhook } from '@slack/webhook'; +import { SlackNotificationsGateway } from './notifications'; + +describe('SlackNotificationsGateway', () => { + // @ts-ignore + const slack: jest.Mocked = { + send: jest.fn(), + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should send a message to slack', async () => { + const gateway = new SlackNotificationsGateway( + slack, + 'http://localhost:3000', + ); + const award: Award = { + uid: '123', + name: 'Coolest Test', + description: 'For great tests', + image: 'image', + owners: [], + recipients: [], + }; + const newRecipients: UserEntity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'taylor-swift', + annotations: { + 'slack.com/user_id': '123', + }, + }, + spec: { + profile: { + displayName: 'Taylor Swift', + }, + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'lebron-james', + annotations: { + 'slack.com/user_id': '456', + }, + }, + spec: { + profile: { + displayName: 'Lebron James', + }, + }, + }, + ]; + + await gateway.notifyNewRecipientsAdded(award, newRecipients); + + expect(slack.send).toHaveBeenCalledWith({ + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: ':trophy: The following users have received the Coolest Test Award :trophy:', + emoji: true, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ' (<@123>), (<@456>)', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: '> For great tests ()', + }, + }, + ], + }); + }); +}); diff --git a/plugins/awards-backend/src/notifications/notifications.ts b/plugins/awards-backend/src/notifications/notifications.ts new file mode 100644 index 0000000..419f2a4 --- /dev/null +++ b/plugins/awards-backend/src/notifications/notifications.ts @@ -0,0 +1,90 @@ +/* + * Copyright SeatGeek + * Licensed under the terms of the Apache-2.0 license. See LICENSE file in project root for terms. + */ +import { UserEntity } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { Award } from '@seatgeek/backstage-plugin-awards-common'; +import { IncomingWebhook } from '@slack/webhook'; + +/** + * Interface for sending notifications about awards + */ +export interface NotificationsGateway { + notifyNewRecipientsAdded( + award: Award, + newRecipients: UserEntity[], + ): Promise; +} + +export class SlackNotificationsGateway implements NotificationsGateway { + private readonly slack: IncomingWebhook; + private readonly backstageBaseUrl: string; + + constructor(slack: IncomingWebhook, backstageBaseUrl: string) { + this.slack = slack; + this.backstageBaseUrl = backstageBaseUrl; + } + + static fromConfig(config: Config): SlackNotificationsGateway | null { + const webhookUrl = config.getOptionalString( + 'awards.notifications.slack.webhook.url', + ); + if (!webhookUrl) { + return null; + } + const slack = new IncomingWebhook(webhookUrl); + const backstageBaseUrl = config.getString('app.baseUrl'); + return new SlackNotificationsGateway(slack, backstageBaseUrl); + } + + private viewUrl = (award: Award): string => { + return `${this.backstageBaseUrl}/awards/view/${award.uid}`; + }; + + private renderRecipient = (recipient: UserEntity): string => { + const namespace = recipient.metadata.namespace || 'default'; + const url = `${this.backstageBaseUrl}/catalog/${namespace}/${recipient.kind}/${recipient.metadata.name}`; + const name = recipient.spec.profile?.displayName || recipient.metadata.name; + let rendered = `<${url}|${name}>`; + const slackAnnotation = + recipient.metadata.annotations?.['slack.com/user_id']; + if (slackAnnotation) { + rendered += ` (<@${slackAnnotation}>)`; + } + + return rendered; + }; + + async notifyNewRecipientsAdded( + award: Award, + newRecipients: UserEntity[], + ): Promise { + await this.slack.send({ + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: `:trophy: The following users have received the ${award.name} Award :trophy:`, + emoji: true, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: newRecipients.map(this.renderRecipient).join(', '), + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `> ${award.description} (<${this.viewUrl(award)}|More info>)`, + }, + }, + ], + }); + } +} diff --git a/plugins/awards-backend/src/notifier.test.ts b/plugins/awards-backend/src/notifier.test.ts new file mode 100644 index 0000000..71da2ec --- /dev/null +++ b/plugins/awards-backend/src/notifier.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright SeatGeek + * Licensed under the terms of the Apache-2.0 license. See LICENSE file in project root for terms. + */ +import { TokenManager } from '@backstage/backend-common'; +import { + CatalogClient, + CatalogRequestOptions, + GetEntitiesByRefsRequest, +} from '@backstage/catalog-client'; +import { UserEntity } from '@backstage/catalog-model'; +import { NotificationsGateway } from './notifications/notifications'; +import { MultiAwardsNotifier } from './notifier'; + +function makeUser(ref: string): UserEntity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: ref, + }, + spec: {}, + }; +} + +describe('MultiAwardsNotifier', () => { + const tokenManager: jest.Mocked = { + getToken: jest.fn().mockResolvedValue({ token: 'token' }), + authenticate: jest.fn(), + }; + const catalogClient: jest.Mocked = { + getEntitiesByRefs: jest + .fn() + .mockImplementation( + async ( + request: GetEntitiesByRefsRequest, + _?: CatalogRequestOptions, + ) => { + return { + items: request.entityRefs.map(makeUser), + }; + }, + ), + } as unknown as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('notifyNewRecipients', () => { + const award = { + uid: 'uid', + name: 'name', + description: 'description', + image: 'image', + owners: ['owner'], + recipients: ['user:default/joni-mitchell', 'user:default/jeff-buckley'], + }; + + it('notifies new recipients', async () => { + const gateway: jest.Mocked = { + notifyNewRecipientsAdded: jest.fn(), + }; + const notifier = new MultiAwardsNotifier( + [gateway], + catalogClient, + tokenManager, + ); + + await notifier.notifyNewRecipients(award, ['user:default/jeff-buckley']); + + expect(catalogClient.getEntitiesByRefs).toHaveBeenCalledWith( + { + entityRefs: ['user:default/jeff-buckley'], + }, + { token: 'token' }, + ); + + expect(gateway.notifyNewRecipientsAdded).toHaveBeenCalledWith(award, [ + makeUser('user:default/jeff-buckley'), + ]); + }); + + it('does not fetch from the catalog if there are no gateways', async () => { + const notifier = new MultiAwardsNotifier([], catalogClient, tokenManager); + + await notifier.notifyNewRecipients(award, ['user:default/jeff-buckley']); + + expect(tokenManager.getToken).not.toHaveBeenCalled(); + expect(catalogClient.getEntitiesByRefs).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/plugins/awards-backend/src/notifier.ts b/plugins/awards-backend/src/notifier.ts new file mode 100644 index 0000000..05b6341 --- /dev/null +++ b/plugins/awards-backend/src/notifier.ts @@ -0,0 +1,63 @@ +/* + * Copyright SeatGeek + * Licensed under the terms of the Apache-2.0 license. See LICENSE file in project root for terms. + */ +import { TokenManager } from '@backstage/backend-common'; +import { CatalogClient } from '@backstage/catalog-client'; +import { isUserEntity } from '@backstage/catalog-model'; +import { Award } from '@seatgeek/backstage-plugin-awards-common'; +import { NotificationsGateway } from './notifications/notifications'; + +function nonNullable(value: T): value is NonNullable { + return value !== null && value !== undefined; +} + +/** + * Interface for handling interactions between Awards and NotificationsGateways. + */ +export interface AwardsNotifier { + notifyNewRecipients(award: Award, newRecipients: string[]): Promise; +} + +export class MultiAwardsNotifier implements AwardsNotifier { + private readonly notificationsGateways: NotificationsGateway[]; + private readonly catalogClient: CatalogClient; + private readonly tokenManager: TokenManager; + + constructor( + notificationsGateways: NotificationsGateway[], + catalogClient: CatalogClient, + tokenManager: TokenManager, + ) { + this.notificationsGateways = notificationsGateways; + this.catalogClient = catalogClient; + this.tokenManager = tokenManager; + } + + addNotificationsGateway(gateway: NotificationsGateway) { + this.notificationsGateways.push(gateway); + } + + async notifyNewRecipients( + award: Award, + newRecipients: string[], + ): Promise { + if (this.notificationsGateways.length === 0) { + return; + } + + const token = await this.tokenManager.getToken(); + const resp = await this.catalogClient.getEntitiesByRefs( + { + entityRefs: newRecipients, + }, + token, + ); + const users = resp.items.filter(nonNullable).filter(isUserEntity); + await Promise.all( + this.notificationsGateways.map(gateway => + gateway.notifyNewRecipientsAdded(award, users), + ), + ); + } +} diff --git a/plugins/awards-backend/src/plugin.ts b/plugins/awards-backend/src/plugin.ts index d5d7b46..613cdca 100644 --- a/plugins/awards-backend/src/plugin.ts +++ b/plugins/awards-backend/src/plugin.ts @@ -16,15 +16,29 @@ export const awardsPlugin = createBackendPlugin({ deps: { database: coreServices.database, identity: coreServices.identity, + config: coreServices.rootConfig, logger: coreServices.logger, httpRouter: coreServices.httpRouter, + discovery: coreServices.discovery, + tokenManager: coreServices.tokenManager, }, - async init({ database, identity, logger, httpRouter }) { + async init({ + database, + identity, + config, + logger, + httpRouter, + discovery, + tokenManager, + }) { httpRouter.use( await createRouter({ database, identity, + config, logger: loggerToWinstonLogger(logger), + discovery, + tokenManager, }), ); }, diff --git a/plugins/awards-backend/src/service/router.test.ts b/plugins/awards-backend/src/service/router.test.ts index ebd6a8e..fbf92cd 100644 --- a/plugins/awards-backend/src/service/router.test.ts +++ b/plugins/awards-backend/src/service/router.test.ts @@ -55,11 +55,19 @@ describe('backend router', () => { ).forPlugin('awards'); const dbm = await createDatabaseManager(); db = await dbm.getClient(); - const router = await createRouter({ logger: getVoidLogger(), + config: new ConfigReader({}), identity: { getIdentity }, database: dbm, + discovery: { + getBaseUrl: jest.fn().mockResolvedValue('/'), + getExternalBaseUrl: jest.fn().mockResolvedValue('/'), + }, + tokenManager: { + authenticate: jest.fn(), + getToken: jest.fn().mockResolvedValue({ token: 'token' }), + }, }); app = express().use(router); diff --git a/plugins/awards-backend/src/service/router.ts b/plugins/awards-backend/src/service/router.ts index c970585..938c433 100644 --- a/plugins/awards-backend/src/service/router.ts +++ b/plugins/awards-backend/src/service/router.ts @@ -2,7 +2,14 @@ * Copyright SeatGeek * Licensed under the terms of the Apache-2.0 license. See LICENSE file in project root for terms. */ -import { PluginDatabaseManager, errorHandler } from '@backstage/backend-common'; +import { + PluginDatabaseManager, + TokenManager, + errorHandler, +} from '@backstage/backend-common'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; +import { CatalogClient } from '@backstage/catalog-client'; +import { Config } from '@backstage/config'; import { AuthenticationError } from '@backstage/errors'; import { IdentityApi } from '@backstage/plugin-auth-node'; import express from 'express'; @@ -10,20 +17,38 @@ import Router from 'express-promise-router'; import { Logger } from 'winston'; import { Awards } from '../awards'; import { DatabaseAwardsStore } from '../database/awards'; +import { SlackNotificationsGateway } from '../notifications/notifications'; +import { MultiAwardsNotifier } from '../notifier'; export interface RouterOptions { identity: IdentityApi; database: PluginDatabaseManager; logger: Logger; + config: Config; + discovery: DiscoveryService; + tokenManager: TokenManager; } export async function createRouter( options: RouterOptions, ): Promise { - const { database, identity, logger } = options; + const { config, database, identity, logger } = options; + const catalogClient = new CatalogClient({ + discoveryApi: options.discovery, + }); + const notifier = new MultiAwardsNotifier( + [], + catalogClient, + options.tokenManager, + ); + const slack = SlackNotificationsGateway.fromConfig(config); + if (slack) { + notifier.addNotificationsGateway(slack); + } const dbStore = await DatabaseAwardsStore.create({ database: database }); - const awardsApp = new Awards(dbStore, logger); + + const awardsApp = new Awards(dbStore, notifier, logger); const router = Router(); router.use(express.json()); @@ -106,7 +131,6 @@ export async function createRouter( }); router.post('/', async (request, response) => { - // Just to protect the request await getUserRef(identity, request); const award = await awardsApp.create(request.body); diff --git a/plugins/awards-backend/src/service/standaloneServer.ts b/plugins/awards-backend/src/service/standaloneServer.ts index 776e4b0..00d0b87 100644 --- a/plugins/awards-backend/src/service/standaloneServer.ts +++ b/plugins/awards-backend/src/service/standaloneServer.ts @@ -5,6 +5,7 @@ import { DatabaseManager, HostDiscovery, + ServerTokenManager, createServiceBuilder, loadBackendConfig, } from '@backstage/backend-common'; @@ -45,6 +46,11 @@ export async function startStandaloneServer( database, identity, logger, + config, + discovery, + tokenManager: ServerTokenManager.fromConfig(config, { + logger, + }), }); let service = createServiceBuilder(module) diff --git a/yarn.lock b/yarn.lock index 92a41a3..5029d22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2651,6 +2651,16 @@ cross-fetch "^4.0.0" uri-template "^2.0.0" +"@backstage/catalog-client@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@backstage/catalog-client/-/catalog-client-1.6.0.tgz#d4ba505f84a58f03177d0998becc6eb8ed54f40e" + integrity sha512-O6yoBX/BcKy89AwXmXVxNPlk0mX7jbgqYUCeIxGZr7n10A9oJx1iRj1XMub+V67yuqdfILPmh8WW+jd0N98+JA== + dependencies: + "@backstage/catalog-model" "^1.4.4" + "@backstage/errors" "^1.2.3" + cross-fetch "^4.0.0" + uri-template "^2.0.0" + "@backstage/catalog-model@^1.4.3": version "1.4.3" resolved "https://registry.yarnpkg.com/@backstage/catalog-model/-/catalog-model-1.4.3.tgz#64abf34071d1cad6372f905b92e1d831e480750c" @@ -2661,6 +2671,16 @@ ajv "^8.10.0" lodash "^4.17.21" +"@backstage/catalog-model@^1.4.4": + version "1.4.4" + resolved "https://registry.yarnpkg.com/@backstage/catalog-model/-/catalog-model-1.4.4.tgz#53ebbe754c72a0e01bb7ea025af0358dc459db9c" + integrity sha512-JiCeAgUdRMQTjO0+34QeKDxYh/UQrXtDUvVic5z11uf8WuX3L9N7LiPOqJG+3t9TAyc5side21nDD7REdHoVFA== + dependencies: + "@backstage/errors" "^1.2.3" + "@backstage/types" "^1.1.1" + ajv "^8.10.0" + lodash "^4.17.21" + "@backstage/cli-common@^0.1.13": version "0.1.13" resolved "https://registry.yarnpkg.com/@backstage/cli-common/-/cli-common-0.1.13.tgz#cbeda6a359ca4437fc782f0ac51bb957e8d49e73" @@ -7434,7 +7454,7 @@ dependencies: "@types/node" ">=12.0.0" -"@slack/types@^2.11.0": +"@slack/types@^2.11.0", "@slack/types@^2.9.0": version "2.11.0" resolved "https://registry.yarnpkg.com/@slack/types/-/types-2.11.0.tgz#948c556081c3db977dfa8433490cc2ff41f47203" integrity sha512-UlIrDWvuLaDly3QZhCPnwUSI/KYmV1N9LyhuH6EDKCRS1HWZhyTG3Ja46T3D0rYfqdltKYFXbJSSRPwZpwO0cQ== @@ -7456,6 +7476,15 @@ p-queue "^6.6.1" p-retry "^4.0.0" +"@slack/webhook@^7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@slack/webhook/-/webhook-7.0.2.tgz#e69d4c8116584fab90e40a1076233de95b737d07" + integrity sha512-dsrO/ow6a6+xkLm/lZKbUNTsFJlBc679tD+qwlVTztsQkDxPLH6odM7FKALz1IHa+KpLX8HKUIPV13a7y7z29w== + dependencies: + "@slack/types" "^2.9.0" + "@types/node" ">=18.0.0" + axios "^1.6.3" + "@smithy/abort-controller@^1.0.1": version "1.1.0" resolved "https://registry.yarnpkg.com/@smithy/abort-controller/-/abort-controller-1.1.0.tgz#2da0d73c504b93ca8bb83bdc8d6b8208d73f418b" @@ -9313,6 +9342,13 @@ dependencies: undici-types "~5.26.4" +"@types/node@>=18.0.0": + version "20.11.20" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.20.tgz#f0a2aee575215149a62784210ad88b3a34843659" + integrity sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg== + dependencies: + undici-types "~5.26.4" + "@types/node@^12.7.1": version "12.20.55" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240" @@ -10692,7 +10728,7 @@ axios@^0.21.1: dependencies: follow-redirects "^1.14.0" -axios@^1.0.0, axios@^1.4.0, axios@^1.6.0, axios@^1.6.5: +axios@^1.0.0, axios@^1.4.0, axios@^1.6.0, axios@^1.6.3, axios@^1.6.5: version "1.6.7" resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.7.tgz#7b48c2e27c96f9c68a2f8f31e2ab19f59b06b0a7" integrity sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==