diff --git a/README.md b/README.md index 439997c..2724bac 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ and reusable Slack code declaratively and ready for production. - Used in many production workloads. - Building blocks with [slack-block-builder]. - Supports sending messages directly to Slack Web API. +- Supports Slack webhooks. - Supports Google Logging. [slack-block-builder]: https://github.com/raycharius/slack-block-builder @@ -66,6 +67,17 @@ import { SlackModule } from 'nestjs-slack'; export class AppModule {} ``` +To use `webhook` type, you'll typically use these settings: + +```typescript +SlackModule.forRoot({ + type: 'webhook', + webhookOptions: { + url: '', + }, +}), +``` + ### Example You can easily inject `SlackService` to be used in your services, controllers, diff --git a/package.json b/package.json index 68500fa..0a129c1 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@cobraz/prettier-config": "^1.0.1", + "@google-cloud/logging": "^9.5.5", "@indivorg/eslint-config": "^2.0.0", "@nestjs/common": "7.6.15", "@nestjs/core": "7.6.15", @@ -35,11 +36,11 @@ "husky": "7", "jest": "26.6.3", "lint-staged": "11.1.2", + "nock": "^13.2.1", "prettier": "^2.3.2", "reflect-metadata": "^0.1.13", "slack-block-builder": "^2.1.0", "ts-jest": "^26.4.4", - "@google-cloud/logging": "^9.5.5", "typescript": "4.1.4" }, "lint-staged": { @@ -49,12 +50,13 @@ }, "dependencies": { "@slack/web-api": "^6", + "axios": "^0.24.0", "slack-block-builder": "^2" }, "peerDependencies": { + "@google-cloud/logging": "^9", "@nestjs/common": ">=7 <=8", - "@nestjs/core": ">=7 <=8", - "@google-cloud/logging": "^9" + "@nestjs/core": ">=7 <=8" }, "peerDependenciesMeta": { "@google-cloud/logging": { diff --git a/src/__tests__/google.test.ts b/src/__tests__/google.test.ts index 9b15b31..0873196 100644 --- a/src/__tests__/google.test.ts +++ b/src/__tests__/google.test.ts @@ -3,6 +3,7 @@ import { Test } from '@nestjs/testing'; import { GOOGLE_LOGGING, SLACK_MODULE_OPTIONS, + SLACK_WEBHOOK_URL, SLACK_WEB_CLIENT, } from '../constants'; import { SlackService } from '../slack.service'; @@ -24,6 +25,10 @@ describe('google logging', () => { provide: SLACK_WEB_CLIENT, useValue: null, }, + { + provide: SLACK_WEBHOOK_URL, + useValue: null, + }, { provide: GOOGLE_LOGGING, useValue: { diff --git a/src/__tests__/webhook.test.ts b/src/__tests__/webhook.test.ts new file mode 100644 index 0000000..7167dd1 --- /dev/null +++ b/src/__tests__/webhook.test.ts @@ -0,0 +1,48 @@ +import { SlackService } from '../slack.service'; +import { createApp } from './fixtures'; +import * as nock from 'nock'; + +describe('webhook', () => { + let service: SlackService; + let output: any; + + const baseUrl = 'https://example.com'; + + beforeEach(async () => { + output = jest.fn(); + const app = await createApp({ + type: 'webhook', + webhookOptions: { url: `${baseUrl}/webhook` }, + }); + service = app.get(SlackService); + }); + + it('must send requests to API', async () => { + // nock.recorder.rec(); + const scope = nock(baseUrl, { encodedQueryParams: true }) + .post('/webhook', { + text: 'hello-world', + }) + .reply(200, 'ok'); + + await service.postMessage({ text: 'hello-world' }); + + scope.done(); + }); + + it('Should throw when request fails', () => { + nock(baseUrl, { encodedQueryParams: true }) + .post('/webhook', { + text: 'hello-world', + }) + .reply(500, 'fail'); + + return service + .postMessage({ text: 'hello-world' }) + .catch(error => + expect(error).toMatchInlineSnapshot( + `[Error: Could not send request to Slack Webhook: fail]`, + ), + ); + }); +}); diff --git a/src/constants.ts b/src/constants.ts index 9331748..8408497 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,4 @@ export const SLACK_MODULE_OPTIONS = 'SlackModuleOptions'; export const SLACK_WEB_CLIENT = 'SlackWebClient'; +export const SLACK_WEBHOOK_URL = 'SlackWebhookUrl'; export const GOOGLE_LOGGING = 'SlackGoogleLogging'; diff --git a/src/slack.module.ts b/src/slack.module.ts index 339937c..a8ad1d0 100644 --- a/src/slack.module.ts +++ b/src/slack.module.ts @@ -3,6 +3,7 @@ import type { WebClientOptions } from '@slack/web-api'; import { GOOGLE_LOGGING, SLACK_MODULE_OPTIONS, + SLACK_WEBHOOK_URL, SLACK_WEB_CLIENT, } from './constants'; import { SlackService } from './slack.service'; @@ -18,7 +19,18 @@ export interface SlackApiOptions { clientOptions?: WebClientOptions; } -export type SlackRequestType = 'api' | 'stdout' | 'google'; +export interface SlackWebhookOptions { + /** + * Incoming Webhooks are a simple way to post messages from apps into Slack. + * Creating an Incoming Webhook gives you a unique URL to which you send a + * JSON payload with the message text and some options. + * + * Read more: https://api.slack.com/messaging/webhooks + */ + url: string; +} + +export type SlackRequestType = 'api' | 'webhook' | 'stdout' | 'google'; export interface SlackConfig { /** @@ -51,6 +63,12 @@ export interface SlackConfig { */ apiOptions?: SlackApiOptions; + /** + * These configuration options are only required when type is set to + * `api`. + */ + webhookOptions?: SlackWebhookOptions; + /** * Setting this changes which function is used to stdout. * @@ -82,6 +100,7 @@ export class SlackModule { }, this.createAsyncGoogleLogger(options), this.createAsyncWebClient(options), + this.createAsyncWebhook(options), ]; return { global: options.isGlobal, @@ -134,4 +153,26 @@ export class SlackModule { }, }; } + + private static createAsyncWebhook({ type }: SlackConfig): Provider { + if (type !== 'webhook') { + return { + provide: SLACK_WEBHOOK_URL, + useValue: null, + }; + } + + return { + provide: SLACK_WEBHOOK_URL, + inject: [SLACK_MODULE_OPTIONS], + useFactory: async (opts: SlackConfig) => { + invariant( + opts.webhookOptions, + 'You must provide `webhookOptions` when using the webhook type.', + ); + + return opts.webhookOptions.url; + }, + }; + } } diff --git a/src/slack.service.ts b/src/slack.service.ts index 3438892..8dc5671 100644 --- a/src/slack.service.ts +++ b/src/slack.service.ts @@ -1,10 +1,12 @@ import type { LogSync } from '@google-cloud/logging'; +import axios, { AxiosError } from 'axios'; import { Inject, Injectable } from '@nestjs/common'; import type { ChatPostMessageArguments, WebClient } from '@slack/web-api'; import type { SlackBlockDto } from 'slack-block-builder'; import { GOOGLE_LOGGING, SLACK_MODULE_OPTIONS, + SLACK_WEBHOOK_URL, SLACK_WEB_CLIENT, } from './constants'; import type { SlackConfig, SlackRequestType } from './slack.module'; @@ -18,6 +20,7 @@ export class SlackService { @Inject(SLACK_MODULE_OPTIONS) private readonly options: SlackConfig, @Inject(SLACK_WEB_CLIENT) private readonly client: WebClient | null, @Inject(GOOGLE_LOGGING) private readonly log: LogSync | null, + @Inject(SLACK_WEBHOOK_URL) private readonly webhookUrl: string | null, ) {} /** @@ -103,7 +106,7 @@ export class SlackService { * @param opts */ async postMessage(req: SlackMessageOptions): Promise { - if (!req.channel) { + if (!req.channel && this.options.type !== 'webhook') { invariant( this.options.defaultChannel, 'neither channel nor defaultChannel was applied', @@ -113,6 +116,7 @@ export class SlackService { const requestTypes: Record Promise> = { api: async () => this.runApiRequest(req), + webhook: async () => this.runWebhookRequest(req), stdout: async () => this.runStdoutRequest(req), google: async () => this.runGoogleLoggingRequest(req), }; @@ -129,6 +133,18 @@ export class SlackService { await this.client.chat.postMessage(req as ChatPostMessageArguments); } + private async runWebhookRequest(req: SlackMessageOptions) { + invariant(this.webhookUrl, 'expected webhook url to exist'); + + await axios.post(this.webhookUrl, req).catch((error: AxiosError) => { + invariant(error.response); + + throw new Error( + `Could not send request to Slack Webhook: ${error.response.data}`, + ); + }); + } + private async runStdoutRequest(req: SlackMessageOptions) { this.options.output(req); } diff --git a/yarn.lock b/yarn.lock index 988bab2..6f52c4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -963,19 +963,19 @@ resolved "https://registry.yarnpkg.com/@slack/types/-/types-2.1.0.tgz#3b8d9fc600938ac3b55c39d93d7d4be74b380212" integrity sha512-6FSyuKt3dqmXpKxu5cH7vCm2Ekj7WpyaYyznTQ/SpKZKkdLvpkcp0/HbPFTtDI9O1wHGeaPgs6h3AtZmRRDXjA== -"@slack/web-api@^6.3.0": - version "6.4.0" - resolved "https://registry.yarnpkg.com/@slack/web-api/-/web-api-6.4.0.tgz#fe8212e4aca50c4cbafe4dac3f4b81c84c527423" - integrity sha512-Hi0pq60d/zCqn1UQvuSyrMcoLGNbKUBL/Tmk1b1RPTZdVYiRK8zp337glvhxTBwiaGOu+58uO5yflpK1AAuoRw== +"@slack/web-api@^6": + version "6.5.1" + resolved "https://registry.yarnpkg.com/@slack/web-api/-/web-api-6.5.1.tgz#21a4f055cd7facf8d769cf62c61a40e37a3eb87c" + integrity sha512-W1PDIdHz/GtDpC8afpPUsXMfAQ+sZGwmfxx+Ug83uhRD8zECrypGTmIyCqrCSWzf2qVKT9XvMftZX3m0AmPY8A== dependencies: "@slack/logger" "^3.0.0" "@slack/types" "^2.0.0" "@types/is-stream" "^1.1.0" "@types/node" ">=12.0.0" - axios "^0.21.1" + axios "^0.24.0" eventemitter3 "^3.1.0" form-data "^2.5.0" - is-electron "^2.2.0" + is-electron "2.2.0" is-stream "^1.1.0" p-queue "^6.6.1" p-retry "^4.0.0" @@ -1470,13 +1470,20 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== -axios@0.21.1, axios@^0.21.1: +axios@0.21.1: version "0.21.1" resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== dependencies: follow-redirects "^1.10.0" +axios@^0.24.0: + version "0.24.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.24.0.tgz#804e6fa1e4b9c5288501dd9dff56a7a0940d20d6" + integrity sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA== + dependencies: + follow-redirects "^1.14.4" + babel-jest@^26.6.3: version "26.6.3" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-26.6.3.tgz#d87d25cb0037577a0c89f82e5755c5d293c01056" @@ -2899,6 +2906,11 @@ follow-redirects@^1.10.0: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA== +follow-redirects@^1.14.4: + version "1.14.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.6.tgz#8cfb281bbc035b3c067d6cd975b0f6ade6e855cd" + integrity sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A== + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -3491,7 +3503,7 @@ is-docker@^2.0.0: resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.1.1.tgz#4125a88e44e450d384e09047ede71adc2d144156" integrity sha512-ZOoqiXfEwtGknTiuDEy8pN2CfE3TxMHprvNer1mXiqwkOT77Rw3YVrUQ52EqAOU3QAWDQ+bQdx7HJzrv7LS2Hw== -is-electron@^2.2.0: +is-electron@2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-electron/-/is-electron-2.2.0.tgz#8943084f09e8b731b3a7a0298a7b5d56f6b7eef0" integrity sha512-SpMppC2XR3YdxSzczXReBjqs2zGscWQpBIKqwXYBFic0ERaxNVgwLCHwOLZeESfdJQjX0RDvrJ1lBXX2ij+G1Q== @@ -4218,7 +4230,7 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= -json-stringify-safe@~5.0.1: +json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= @@ -4394,6 +4406,11 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash.set@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23" + integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM= + lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" @@ -4647,6 +4664,16 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +nock@^13.2.1: + version "13.2.1" + resolved "https://registry.yarnpkg.com/nock/-/nock-13.2.1.tgz#fcf5bdb9bb9f0554a84c25d3333166c0ffd80858" + integrity sha512-CoHAabbqq/xZEknubuyQMjq6Lfi5b7RtK6SoNK6m40lebGp3yiMagWtIoYaw2s9sISD7wPuCfwFpivVHX/35RA== + dependencies: + debug "^4.1.0" + json-stringify-safe "^5.0.1" + lodash.set "^4.3.2" + propagate "^2.0.0" + node-fetch@^2.3.0, node-fetch@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" @@ -5147,6 +5174,11 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" +propagate@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" + integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== + proto3-json-serializer@^0.1.1: version "0.1.3" resolved "https://registry.yarnpkg.com/proto3-json-serializer/-/proto3-json-serializer-0.1.3.tgz#3b4d5f481dbb923dd88e259ed03b0629abc9a8e7" @@ -5650,10 +5682,10 @@ sisteransi@^1.0.5: resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== -slack-block-builder@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/slack-block-builder/-/slack-block-builder-2.1.1.tgz#81911112f471d9bcbdd51ad8e5a8d1ac6d9717bd" - integrity sha512-Y5OGAUvEQKWWOIHNINd9F1WwYur9EoQDyBv0EZ814IP9h05QXL2YWfL6/TnNJ7e1Cii8M+KVQwWGn1Lv6VAmOw== +slack-block-builder@^2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/slack-block-builder/-/slack-block-builder-2.4.2.tgz#e34984d4b7c0c5a51cd385b1e4a39239226ccd64" + integrity sha512-VMcXwVpoJ5x3ZcZCsHXmP9XgP7O9x6qJ/OVfKGXrxdqixOUPku65aqQ597Sk+5gv/MUz4jCzSZcnj5Wy4j1LuA== slash@^3.0.0: version "3.0.0"