From 7883216a8c6a77c201327994494c566ebdc54fc7 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 26 Sep 2024 14:44:51 +0200 Subject: [PATCH] feat: zulip release notifications (#91) --- .vscode/launch.json | 14 ++ .vscode/settings.json | 24 ++++ eslint.config.mjs | 3 + package-lock.json | 191 +++++++++++++++++++++++++- package.json | 8 +- src/config.ts | 16 ++- src/constants.ts | 4 + src/controllers/webhook.controller.ts | 6 + src/interfaces/zulip.interface.ts | 9 ++ src/main.ts | 2 + src/repositories/index.ts | 3 + src/repositories/zulip.repository.ts | 34 +++++ src/services/index.ts | 11 +- src/services/webhook.service.ts | 25 +++- src/services/zulip.service.ts | 15 ++ 15 files changed, 353 insertions(+), 12 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 src/interfaces/zulip.interface.ts create mode 100644 src/repositories/zulip.repository.ts create mode 100644 src/services/zulip.service.ts diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..4d68d92 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "attach", + "restart": true, + "port": 9240, + "name": "Immich Bot", + "localRoot": "${workspaceFolder}" + }, + ] +} + diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a38e33a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,24 @@ +{ + "editor.formatOnSave": true, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.tabSize": 2, + "editor.formatOnSave": true + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.tabSize": 2, + "editor.formatOnSave": true + }, + "eslint.validate": [ + "javascript", + ], + "typescript.preferences.importModuleSpecifier": "non-relative", + "cSpell.words": [ + "immich" + ], + "explorer.fileNesting.enabled": true, + "explorer.fileNesting.patterns": { + "*.ts": "${capture}.spec.ts,${capture}.mock.ts" + } +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 5bb07b7..c0fcfe1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,6 +2,7 @@ import { FlatCompat } from '@eslint/eslintrc'; import js from '@eslint/js'; import typescriptEslint from '@typescript-eslint/eslint-plugin'; import tsParser from '@typescript-eslint/parser'; +import noRelativeImportPaths from 'eslint-plugin-no-relative-import-paths'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -33,6 +34,7 @@ export default [ { plugins: { '@typescript-eslint': typescriptEslint, + 'no-relative-import-paths': noRelativeImportPaths, }, languageOptions: { @@ -49,6 +51,7 @@ export default [ rules: { '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-explicit-any': 'off', + 'no-relative-import-paths/no-relative-import-paths': 'error', }, }, ]; diff --git a/package-lock.json b/package-lock.json index acfcfe9..60554ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,8 @@ "@octokit/request-error": "^5.0.1", "@octokit/rest": "^20.0.2", "@octokit/types": "^13.4.1", + "@octokit/webhooks": "^12.2.0", + "@octokit/webhooks-types": "^7.5.1", "@types/lodash": "^4.14.200", "@types/luxon": "^3.3.3", "class-transformer": "^0.5.1", @@ -32,7 +34,8 @@ "lodash": "^4.17.21", "luxon": "^3.4.3", "openid-client": "^5.6.5", - "pg": "^8.12.0" + "pg": "^8.12.0", + "zulip-js": "^2.0.9" }, "devDependencies": { "@nestjs/cli": "^10.4.2", @@ -47,6 +50,7 @@ "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.0", "eslint": "^9.0.0", + "eslint-plugin-no-relative-import-paths": "^1.5.5", "prettier": "^3.0.3", "prettier-plugin-organize-imports": "^4.0.0", "typescript": "^5.2.2", @@ -403,6 +407,18 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz", + "integrity": "sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.24.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", @@ -1912,6 +1928,42 @@ "@octokit/openapi-types": "^22.2.0" } }, + "node_modules/@octokit/webhooks": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-12.2.0.tgz", + "integrity": "sha512-CyuLJ0/P7bKZ+kIYw+fnkeVdhUzNuDKgNSI7pU/m7Nod0T7kP+s4s2f0pNmG9HL8/RZN1S0ZWTDll3VTMrFLAw==", + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^5.0.0", + "@octokit/webhooks-methods": "^4.1.0", + "@octokit/webhooks-types": "7.4.0", + "aggregate-error": "^3.1.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/webhooks-methods": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@octokit/webhooks-methods/-/webhooks-methods-4.1.0.tgz", + "integrity": "sha512-zoQyKw8h9STNPqtm28UGOYFE7O6D4Il8VJwhAtMHFt2C4L0VQT1qGKLeefUOqHNs1mNRYSadVv7x0z8U2yyeWQ==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/webhooks-types": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.5.1.tgz", + "integrity": "sha512-1dozxWEP8lKGbtEu7HkRbK1F/nIPuJXNfT0gd96y6d3LcHZTtRtlf8xz3nicSJfesADxJyDh+mWBOsdLkqgzYw==", + "license": "MIT" + }, + "node_modules/@octokit/webhooks/node_modules/@octokit/webhooks-types": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.4.0.tgz", + "integrity": "sha512-FE2V+QZ2UYlh+9wWd5BPLNXG+J/XUD/PPq0ovS+nCcGX4+3qVbi3jYOmCTW48hg9SBBLtInx9+o7fFt4H5iP0Q==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3197,6 +3249,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3363,6 +3428,12 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3716,6 +3787,15 @@ "validator": "^13.9.0" } }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -3789,6 +3869,18 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -4055,6 +4147,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4347,6 +4448,22 @@ } } }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "extraneous": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-no-relative-import-paths": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-no-relative-import-paths/-/eslint-plugin-no-relative-import-paths-1.5.5.tgz", + "integrity": "sha512-UjudFFdBbv93v0CsVdEKcMLbBzRIjeK2PubTctX57tgnHxZcMj1Jm8lDBWoETnPxk0S5g5QLSltEM+511yL4+w==", + "dev": true, + "license": "ISC" + }, "node_modules/eslint-scope": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.2.tgz", @@ -4864,6 +4981,20 @@ "node": "*" } }, + "node_modules/form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5174,11 +5305,26 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/inquirer": { "version": "8.2.6", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", @@ -5305,6 +5451,25 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/isomorphic-form-data": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-form-data/-/isomorphic-form-data-2.0.0.tgz", + "integrity": "sha512-TYgVnXWeESVmQSg4GLVbalmQ+B4NPi/H4eWxqALKj63KsUrcu301YDjBqaOw3h+cbak7Na4Xyps3BiptHtxTfg==", + "license": "MIT", + "dependencies": { + "form-data": "^2.3.2" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -6521,6 +6686,12 @@ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "peer": true }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, "node_modules/repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", @@ -7821,6 +7992,12 @@ "node": ">=4.0" } }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -7951,6 +8128,18 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zulip-js": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/zulip-js/-/zulip-js-2.0.9.tgz", + "integrity": "sha512-I8Cjnxa7qTaHwxN6YZ4IOL2IiTz89rD4NZul1t8Hzu+q8muSE4LT2iVAlnCrCut4KEbOZDA+Bsgp0/CtFkUbnA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "ini": "^1.3.7", + "isomorphic-fetch": "^3.0.0", + "isomorphic-form-data": "2.0.0" + } } } } diff --git a/package.json b/package.json index 146f55b..696afe4 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "start": "npm run start:dev", "nest": "nest", "start:dev": "nest start --watch --", - "start:debug": "nest start --debug 0.0.0.0:9230 --watch --", + "start:debug": "nest start --debug 9240 --watch --", "lint": "eslint \"src/**/*.ts\" --max-warnings 0", "lint:fix": "npm run lint -- --fix", "check": "tsc --noEmit", @@ -31,6 +31,8 @@ "@octokit/request-error": "^5.0.1", "@octokit/rest": "^20.0.2", "@octokit/types": "^13.4.1", + "@octokit/webhooks": "^12.2.0", + "@octokit/webhooks-types": "^7.5.1", "@types/lodash": "^4.14.200", "@types/luxon": "^3.3.3", "class-transformer": "^0.5.1", @@ -45,7 +47,8 @@ "lodash": "^4.17.21", "luxon": "^3.4.3", "openid-client": "^5.6.5", - "pg": "^8.12.0" + "pg": "^8.12.0", + "zulip-js": "^2.0.9" }, "devDependencies": { "@nestjs/cli": "^10.4.2", @@ -60,6 +63,7 @@ "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.0", "eslint": "^9.0.0", + "eslint-plugin-no-relative-import-paths": "^1.5.5", "prettier": "^3.0.3", "prettier-plugin-organize-imports": "^4.0.0", "typescript": "^5.2.2", diff --git a/src/config.ts b/src/config.ts index 29d5289..fe21ba9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,12 +7,16 @@ export const getConfig = () => { const clientSecret = process.env.IMMICH_GITHUB_CLIENT_SECRET; const databaseUri = process.env.uri; const botToken = process.env.BOT_TOKEN; - const githubWebhookSlug = process.env.GITHUB_STATUS_SLUG; + const zulipUsername = process.env.ZULIP_USERNAME; + const zulipApiKey = process.env.ZULIP_API_KEY; + const zulipDomain = process.env.ZULIP_DOMAIN; + const githubWebhookSlug = process.env.GITHUB_SLUG; + const githubStatusWebhookSlug = process.env.GITHUB_STATUS_SLUG; const stripeWebhookSlug = process.env.STRIPE_PAYMENT_SLUG; const commitSha = process.env.COMMIT_SHA; - if (!clientId || !clientSecret || !databaseUri || !botToken) { - console.log({ clientId, clientSecret, databaseUri, botToken }); + if (!clientId || !clientSecret || !databaseUri || !botToken || !zulipUsername || !zulipApiKey || !zulipDomain) { + console.log({ clientId, clientSecret, databaseUri, botToken, zulipUsername, zulipApiKey, zulipDomain }); throw new Error('Missing required environment variables'); } @@ -30,7 +34,13 @@ export const getConfig = () => { }, slugs: { githubWebhook: githubWebhookSlug, + githubStatusWebhook: githubStatusWebhookSlug, stripeWebhook: stripeWebhookSlug, }, + zulip: { + username: zulipUsername, + apiKey: zulipApiKey, + realm: zulipDomain, + }, }; }; diff --git a/src/constants.ts b/src/constants.ts index 2caa368..c738d89 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -86,6 +86,10 @@ export const Constants = { WeeklyReport: '0 12 * * 4', MonthlyReport: '0 12 19 * *', }, + Zulip: { + Streams: { Immich: 54 }, + Topics: { ImmichRelease: 'release' }, + }, }; export const HELP_TEXTS = { diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts index 5791142..611ec79 100644 --- a/src/controllers/webhook.controller.ts +++ b/src/controllers/webhook.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Injectable, Param, Post } from '@nestjs/common'; +import { WebhookEvent } from '@octokit/webhooks-types'; import { GithubStatusComponent, GithubStatusIncident, StripeBase } from 'src/dtos/webhook.dto'; import { WebhookService } from 'src/services/webhook.service'; @@ -7,6 +8,11 @@ import { WebhookService } from 'src/services/webhook.service'; export class WebhookController { constructor(private service: WebhookService) {} + @Post('github/:slug') + async onGithub(@Body() dto: WebhookEvent, @Param('slug') slug: string) { + await this.service.onGithub(dto, slug); + } + @Post('github-status/:slug') async onGithubStatus(@Body() dto: GithubStatusIncident | GithubStatusComponent, @Param('slug') slug: string) { await this.service.onGithubStatus(dto, slug); diff --git a/src/interfaces/zulip.interface.ts b/src/interfaces/zulip.interface.ts new file mode 100644 index 0000000..b737d9b --- /dev/null +++ b/src/interfaces/zulip.interface.ts @@ -0,0 +1,9 @@ +export const IZulipInterface = 'IZulipInterface'; + +export type ZulipConfig = { username: string; apiKey: string; realm: string }; +export type MessagePayload = { stream: string | number; topic?: string; content: string }; + +export interface IZulipInterface { + init(config: ZulipConfig): Promise; + sendMessage(payload: MessagePayload): Promise; +} diff --git a/src/main.ts b/src/main.ts index ef647c5..70d2cd8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import { NestExpressApplication } from '@nestjs/platform-express'; import { DIService, IDependencyRegistryEngine, InstanceOf } from 'discordx'; import { AppModule } from 'src/app.module'; import { DiscordService } from 'src/services/discord.service'; +import { ZulipService } from 'src/services/zulip.service'; export class NoopRegistryEngine implements IDependencyRegistryEngine { addService(): void {} @@ -40,6 +41,7 @@ async function bootstrap() { DIService.engine = new NestjsRegistryEngine(app); await app.get(DiscordService).init(); + await app.get(ZulipService).init(); await app.listen(port); logger.log(`Immich Api is running on: ${await app.getUrl()}`); } diff --git a/src/repositories/index.ts b/src/repositories/index.ts index 04febe1..bfa66f4 100644 --- a/src/repositories/index.ts +++ b/src/repositories/index.ts @@ -2,13 +2,16 @@ import { Provider } from '@nestjs/common'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDiscordInterface } from 'src/interfaces/discord.interface'; import { IGithubInterface } from 'src/interfaces/github.interface'; +import { IZulipInterface } from 'src/interfaces/zulip.interface'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { DiscordRepository } from 'src/repositories/discord.repository'; import { GithubRepository } from 'src/repositories/github.repository'; +import { ZulipRepository } from 'src/repositories/zulip.repository'; export const providers: Provider[] = [ // { provide: IDatabaseRepository, useClass: DatabaseRepository }, { provide: IDiscordInterface, useClass: DiscordRepository }, { provide: IGithubInterface, useClass: GithubRepository }, + { provide: IZulipInterface, useClass: ZulipRepository }, ]; diff --git a/src/repositories/zulip.repository.ts b/src/repositories/zulip.repository.ts new file mode 100644 index 0000000..86df993 --- /dev/null +++ b/src/repositories/zulip.repository.ts @@ -0,0 +1,34 @@ +import { Logger } from '@nestjs/common'; +import { IZulipInterface, MessagePayload, type ZulipConfig } from 'src/interfaces/zulip.interface'; +// @ts-expect-error: that stupid sdk does not have types +import zulip from 'zulip-js'; + +type Zulip = { + messages: { + send: ({ + to, + type, + topic, + content, + }: { + to: string | number; + type: 'stream' | 'channel' | 'direct'; + topic?: string; + content: string; + }) => Promise; + }; +}; + +export class ZulipRepository implements IZulipInterface { + private logger = new Logger(ZulipRepository.name); + private zulip: Zulip = {} as Zulip; + + async init(config: ZulipConfig) { + this.zulip = await zulip(config); + } + + async sendMessage({ stream, content, topic }: MessagePayload) { + const response = await this.zulip.messages.send({ to: stream, type: 'stream', topic, content }); + this.logger.debug(response); + } +} diff --git a/src/services/index.ts b/src/services/index.ts index 225ef39..9a5af40 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,7 +1,8 @@ import { DatabaseService } from 'src/services/database.service'; -import { DiscordService } from './discord.service'; -import { OAuthService } from './oauth.service'; -import { ReportService } from './report.service'; -import { WebhookService } from './webhook.service'; +import { DiscordService } from 'src/services/discord.service'; +import { OAuthService } from 'src/services/oauth.service'; +import { ReportService } from 'src/services/report.service'; +import { WebhookService } from 'src/services/webhook.service'; +import { ZulipService } from 'src/services/zulip.service'; -export const services = [DatabaseService, DiscordService, OAuthService, ReportService, WebhookService]; +export const services = [DatabaseService, DiscordService, OAuthService, ReportService, WebhookService, ZulipService]; diff --git a/src/services/webhook.service.ts b/src/services/webhook.service.ts index 6fad16e..5adb47e 100644 --- a/src/services/webhook.service.ts +++ b/src/services/webhook.service.ts @@ -1,9 +1,12 @@ import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { WebhookEvent } from '@octokit/webhooks-types'; import { Colors, EmbedBuilder, MessageFlags } from 'discord.js'; import { getConfig } from 'src/config'; +import { Constants } from 'src/constants'; import { GithubStatusComponent, GithubStatusIncident, PaymentIntent, StripeBase } from 'src/dtos/webhook.dto'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { DiscordChannel, IDiscordInterface } from 'src/interfaces/discord.interface'; +import { IZulipInterface } from 'src/interfaces/zulip.interface'; import { makeLicenseFields, withErrorLogging } from 'src/util'; const isIncidentUpdate = (dto: GithubStatusComponent | GithubStatusIncident): dto is GithubStatusIncident => { @@ -23,14 +26,34 @@ export class WebhookService { constructor( @Inject(IDatabaseRepository) private database: IDatabaseRepository, @Inject(IDiscordInterface) private discord: IDiscordInterface, + @Inject(IZulipInterface) private zulip: IZulipInterface, ) {} - async onGithubStatus(dto: GithubStatusIncident | GithubStatusComponent, slug: string) { + async onGithub(dto: WebhookEvent, slug: string) { const { slugs } = getConfig(); if (!slugs.githubWebhook || slug !== slugs.githubWebhook) { throw new UnauthorizedException(); } + if (!('action' in dto)) { + return; + } + + if ('release' in dto && dto.action === 'released') { + await this.zulip.sendMessage({ + stream: Constants.Zulip.Streams.Immich, + topic: Constants.Zulip.Topics.ImmichRelease, + content: `A day with a release is a good day! ${dto.release.html_url} 🚀`, + }); + } + } + + async onGithubStatus(dto: GithubStatusIncident | GithubStatusComponent, slug: string) { + const { slugs } = getConfig(); + if (!slugs.githubStatusWebhook || slug !== slugs.githubStatusWebhook) { + throw new UnauthorizedException(); + } + this.logger.debug(dto); if (isIncidentUpdate(dto)) { diff --git a/src/services/zulip.service.ts b/src/services/zulip.service.ts new file mode 100644 index 0000000..fc6edc0 --- /dev/null +++ b/src/services/zulip.service.ts @@ -0,0 +1,15 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { getConfig } from 'src/config'; +import { IZulipInterface } from 'src/interfaces/zulip.interface'; + +@Injectable() +export class ZulipService { + constructor(@Inject(IZulipInterface) private zulip: IZulipInterface) {} + + async init() { + const { zulip } = getConfig(); + if (zulip.apiKey !== 'dev') { + await this.zulip.init(zulip); + } + } +}