From 4da09560768730c60e8402ba71357a792db66a65 Mon Sep 17 00:00:00 2001 From: Jack Cuthbert Date: Mon, 4 Jan 2021 17:20:05 +1100 Subject: [PATCH] feat: add support for updating multiple slack teams and slack apps BREAKING CHANGE: This requires a completely new configuration format but everything else should remain the same. Closes #106 Closes #99 Closes #114 --- .eslintrc.js | 4 +- .gitignore | 2 +- .prettierrc.js | 6 + Dockerfile | 1 + data/.gitkeep | 0 package-lock.json | 240 +++++++++++------------ package.json | 9 +- readme.md | 77 +++++--- src/config.ts | 49 ----- src/index.ts | 198 +++++++++++-------- src/lib/__tests__/truncateStatus.test.ts | 46 +++++ src/lib/index.ts | 1 + src/lib/truncateStatus.ts | 40 ++++ src/services/Cache.ts | 30 +++ src/services/Config.ts | 82 ++++++++ src/services/LastFM.ts | 113 +++++++++++ src/services/Logger.ts | 18 ++ src/services/Schedule.ts | 32 +++ src/services/SlackAPI.ts | 89 +++++++++ src/services/index.ts | 6 + src/types/lastfm.ts | 108 +++++----- src/types/slack.ts | 56 +++--- src/utils/__tests__/slack.test.ts | 44 ----- src/utils/__tests__/utils.test.ts | 37 ---- src/utils/cache.ts | 28 --- src/utils/errors.ts | 20 -- src/utils/lastFm.ts | 82 -------- src/utils/log.ts | 16 -- src/utils/slack.ts | 122 ------------ src/utils/validation.ts | 61 ------ tsconfig.json | 4 +- 31 files changed, 824 insertions(+), 797 deletions(-) create mode 100644 .prettierrc.js create mode 100644 data/.gitkeep delete mode 100644 src/config.ts create mode 100644 src/lib/__tests__/truncateStatus.test.ts create mode 100644 src/lib/index.ts create mode 100644 src/lib/truncateStatus.ts create mode 100644 src/services/Cache.ts create mode 100644 src/services/Config.ts create mode 100644 src/services/LastFM.ts create mode 100644 src/services/Logger.ts create mode 100644 src/services/Schedule.ts create mode 100644 src/services/SlackAPI.ts create mode 100644 src/services/index.ts delete mode 100644 src/utils/__tests__/slack.test.ts delete mode 100644 src/utils/__tests__/utils.test.ts delete mode 100644 src/utils/cache.ts delete mode 100644 src/utils/errors.ts delete mode 100644 src/utils/lastFm.ts delete mode 100644 src/utils/log.ts delete mode 100644 src/utils/slack.ts delete mode 100644 src/utils/validation.ts diff --git a/.eslintrc.js b/.eslintrc.js index 8cf4700..c47ed8d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,8 +2,7 @@ module.exports = { extends: [ 'standard-with-typescript', 'prettier', - 'prettier/@typescript-eslint', - 'prettier/react' + 'prettier/@typescript-eslint' ], plugins: ['prettier'], rules: { @@ -18,4 +17,3 @@ module.exports = { } } } - diff --git a/.gitignore b/.gitignore index b217bb4..0034cea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ dist -.track.json +data/ diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..a50800d --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,6 @@ +module.exports = { + trailingComma: 'none', + semi: false, + singleQuote: true, + arrowParens: 'avoid' +} diff --git a/Dockerfile b/Dockerfile index efd883b..cc4f81d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ RUN npm install --production FROM node:12-alpine WORKDIR /app +RUN mkdir -p /app/data RUN apk --no-cache update && \ apk --no-cache add tzdata RUN rm -rf /var/cache/apk/* diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json index 1b979ab..5707be9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -503,49 +503,38 @@ "strip-json-comments": "^3.1.1" }, "dependencies": { + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, "ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", "dev": true + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } } } }, - "@hapi/address": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@hapi/address/-/address-4.0.1.tgz", - "integrity": "sha512-0oEP5UiyV4f3d6cBL8F3Z5S7iWSX39Knnl0lY8i+6gfmmIBj44JCBNtcMgwyS+5v7j3VYavNay0NFHDS+UGQcw==", - "requires": { - "@hapi/hoek": "^9.0.0" - } - }, - "@hapi/formula": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-2.0.0.tgz", - "integrity": "sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A==" - }, "@hapi/hoek": { "version": "9.0.4", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.0.4.tgz", "integrity": "sha512-EwaJS7RjoXUZ2cXXKZZxZqieGtc7RbvQhUy8FwDoMQtxWVi14tFjeFCYPZAM1mBCpOpiBpyaZbb9NeHc7eGKgw==" }, - "@hapi/joi": { - "version": "17.1.1", - "resolved": "https://registry.npmjs.org/@hapi/joi/-/joi-17.1.1.tgz", - "integrity": "sha512-p4DKeZAoeZW4g3u7ZeRo+vCDuSDgSvtsB/NpfjXEHTUjSeINAi/RrVOWiVQ1isaoLzMvFEhe8n5065mQq1AdQg==", - "requires": { - "@hapi/address": "^4.0.1", - "@hapi/formula": "^2.0.0", - "@hapi/hoek": "^9.0.0", - "@hapi/pinpoint": "^2.0.0", - "@hapi/topo": "^5.0.0" - } - }, - "@hapi/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw==" - }, "@hapi/topo": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.0.0.tgz", @@ -565,6 +554,27 @@ "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" + }, + "dependencies": { + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + } } }, "@istanbuljs/schema": { @@ -1177,79 +1187,23 @@ } } }, - "@sentry/core": { - "version": "5.29.2", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-5.29.2.tgz", - "integrity": "sha512-7WYkoxB5IdlNEbwOwqSU64erUKH4laavPsM0/yQ+jojM76ErxlgEF0u//p5WaLPRzh3iDSt6BH+9TL45oNZeZw==", - "requires": { - "@sentry/hub": "5.29.2", - "@sentry/minimal": "5.29.2", - "@sentry/types": "5.29.2", - "@sentry/utils": "5.29.2", - "tslib": "^1.9.3" - } - }, - "@sentry/hub": { - "version": "5.29.2", - "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-5.29.2.tgz", - "integrity": "sha512-LaAIo2hwUk9ykeh9RF0cwLy6IRw+DjEee8l1HfEaDFUM6TPGlNNGObMJNXb9/95jzWp7jWwOpQjoIE3jepdQJQ==", - "requires": { - "@sentry/types": "5.29.2", - "@sentry/utils": "5.29.2", - "tslib": "^1.9.3" - } - }, - "@sentry/minimal": { - "version": "5.29.2", - "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-5.29.2.tgz", - "integrity": "sha512-0aINSm8fGA1KyM7PavOBe1GDZDxrvnKt+oFnU0L+bTcw8Lr+of+v6Kwd97rkLRNOLw621xP076dL/7LSIzMuhw==", - "requires": { - "@sentry/hub": "5.29.2", - "@sentry/types": "5.29.2", - "tslib": "^1.9.3" - } - }, - "@sentry/node": { - "version": "5.29.2", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-5.29.2.tgz", - "integrity": "sha512-98m1ZejmJgA+eiz6jEFyYYfp6kJZQnx6d6KrJDMxGfss4YTmmJY57bE4xStnjjk7WINDGzlCiHuk+wJFMBjuoA==", - "requires": { - "@sentry/core": "5.29.2", - "@sentry/hub": "5.29.2", - "@sentry/tracing": "5.29.2", - "@sentry/types": "5.29.2", - "@sentry/utils": "5.29.2", - "cookie": "^0.4.1", - "https-proxy-agent": "^5.0.0", - "lru_map": "^0.3.3", - "tslib": "^1.9.3" - } - }, - "@sentry/tracing": { - "version": "5.29.2", - "resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-5.29.2.tgz", - "integrity": "sha512-iumYbVRpvoU3BUuIooxibydeaOOjl5ysc+mzsqhRs2NGW/C3uKAsFXdvyNfqt3bxtRQwJEhwJByLP2u3pLThpw==", + "@sideway/address": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.0.tgz", + "integrity": "sha512-wAH/JYRXeIFQRsxerIuLjgUu2Xszam+O5xKeatJ4oudShOOirfmsQ1D6LL54XOU2tizpCYku+s1wmU0SYdpoSA==", "requires": { - "@sentry/hub": "5.29.2", - "@sentry/minimal": "5.29.2", - "@sentry/types": "5.29.2", - "@sentry/utils": "5.29.2", - "tslib": "^1.9.3" + "@hapi/hoek": "^9.0.0" } }, - "@sentry/types": { - "version": "5.29.2", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-5.29.2.tgz", - "integrity": "sha512-dM9wgt8wy4WRty75QkqQgrw9FV9F+BOMfmc0iaX13Qos7i6Qs2Q0dxtJ83SoR4YGtW8URaHzlDtWlGs5egBiMA==" + "@sideway/formula": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.0.tgz", + "integrity": "sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==" }, - "@sentry/utils": { - "version": "5.29.2", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-5.29.2.tgz", - "integrity": "sha512-nEwQIDjtFkeE4k6yIk4Ka5XjGRklNLThWLs2xfXlL7uwrYOH2B9UBBOOIRUraBm/g/Xrra3xsam/kRxuiwtXZQ==", - "requires": { - "@sentry/types": "5.29.2", - "tslib": "^1.9.3" - } + "@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" }, "@sinonjs/commons": { "version": "1.8.1", @@ -1423,12 +1377,6 @@ "@types/node": "*" } }, - "@types/hapi__joi": { - "version": "17.1.6", - "resolved": "https://registry.npmjs.org/@types/hapi__joi/-/hapi__joi-17.1.6.tgz", - "integrity": "sha512-y3A1MzNC0FmzD5+ys59RziE1WqKrL13nxtJgrSzjoO7boue5B7zZD2nZLPwrSuUviFjpKFQtgHYSvhDGfIE4jA==", - "dev": true - }, "@types/is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@types/is-stream/-/is-stream-1.1.0.tgz", @@ -1471,6 +1419,18 @@ "pretty-format": "^26.0.0" } }, + "@types/joi": { + "version": "14.3.4", + "resolved": "https://registry.npmjs.org/@types/joi/-/joi-14.3.4.tgz", + "integrity": "sha512-1TQNDJvIKlgYXGNIABfgFp9y0FziDpuGrd799Q5RcnsDu+krD+eeW/0Fs5PHARvWWFelOhIG2OPCo6KbadBM4A==", + "dev": true + }, + "@types/js-yaml": { + "version": "3.12.5", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.5.tgz", + "integrity": "sha512-JCcp6J0GV66Y4ZMDAQCXot4xprYB+Zfd3meK9+INSJeVZwJmHAW30BBEEkPzXswMXuiyReUGOP3GxrADc9wPww==", + "dev": true + }, "@types/json-schema": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz", @@ -1758,6 +1718,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, "requires": { "debug": "4" } @@ -1845,13 +1806,9 @@ "dev": true }, "argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "requires": { - "sprintf-js": "~1.0.2" - } + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "argv-formatter": { "version": "1.0.0", @@ -2689,11 +2646,6 @@ "safe-buffer": "~5.1.1" } }, - "cookie": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", - "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" - }, "copy-descriptor": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", @@ -2816,6 +2768,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, "requires": { "ms": "^2.1.1" }, @@ -2823,7 +2776,8 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true } } }, @@ -3329,6 +3283,15 @@ "v8-compile-cache": "^2.0.3" }, "dependencies": { + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -3352,6 +3315,16 @@ "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", "dev": true }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, "path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4483,6 +4456,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "dev": true, "requires": { "agent-base": "6", "debug": "4" @@ -5622,6 +5596,18 @@ } } }, + "joi": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.3.0.tgz", + "integrity": "sha512-Qh5gdU6niuYbUIUV5ejbsMiiFmBdw8Kcp8Buj2JntszCkCfxJ9Cz76OtHxOZMPXrt5810iDIXs+n1nNVoquHgg==", + "requires": { + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0", + "@sideway/address": "^4.1.0", + "@sideway/formula": "^3.0.0", + "@sideway/pinpoint": "^2.0.0" + } + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5629,13 +5615,11 @@ "dev": true }, "js-yaml": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz", - "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==", - "dev": true, + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.0.0.tgz", + "integrity": "sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q==", "requires": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" } }, "jsbn": { @@ -5880,11 +5864,6 @@ "yallist": "^4.0.0" } }, - "lru_map": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.3.3.tgz", - "integrity": "sha1-tcg1G5Rky9dQM1p5ZQoOwOVhGN0=" - }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -11992,7 +11971,8 @@ "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true }, "tsutils": { "version": "3.18.0", diff --git a/package.json b/package.json index 5b8e370..3b6a8e0 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,9 @@ "@types/chalk": "^2.2.0", "@types/date-fns": "^2.6.0", "@types/fs-extra": "^9.0.6", - "@types/hapi__joi": "^17.1.6", "@types/jest": "^26.0.19", + "@types/joi": "^14.3.4", + "@types/js-yaml": "^3.12.5", "@types/node": "^14.14.19", "@typescript-eslint/eslint-plugin": "^4.9.0", "eslint": "^7.14.0", @@ -46,13 +47,13 @@ "typescript": "^4.1.3" }, "dependencies": { - "@hapi/joi": "^17.1.1", - "@sentry/node": "^5.29.2", "@slack/web-api": "^5.14.0", "axios": "^0.21.1", "chalk": "^4.1.0", "date-fns": "^2.16.1", "env-var": "^7.0.0", - "fs-extra": "^9.0.1" + "fs-extra": "^9.0.1", + "joi": "^17.3.0", + "js-yaml": "^4.0.0" } } diff --git a/readme.md b/readme.md index de3a767..117b032 100644 --- a/readme.md +++ b/readme.md @@ -40,40 +40,35 @@ It looks like this: ## Configuration -All configuration is available via environment variables. Values _without_ defaults are required. - -Variable | Default | Description ----------|---------|------ -`LAST_FM_KEY` | | Access to Last.fm data -`LAST_FM_USERNAME` | | Which user to get track info for -`SLACK_TOKEN` | | Personal "legacy" token for updating your Slack status -`SLACK_EMOJI` | `:headphones:` | Specify which emoji to use in the status -`SLACK_SEPARATOR` | `•` | Specify which character to use as a separator between the track name and artist -`TZ` | `UTC` | Set the timezone -`ACTIVE_HOURS_START` | `8` | The hour of the day to start updating your Slack status -`ACTIVE_HOURS_END` | `18` | The hour of the day to stop updating your Slack status -`UPDATE_INTERVAL` | `1` | The time in minutes to wait until updating your Slack Status -`UPDATE_WEEKENDS` | `undefined` | Provide any value to enable status updates during the weekend -`UPDATE_EXPIRATION` | `10` | The time in minutes to use as a default status expiration length -`SENTRY_DSN` | `undefined` | Optionally provide a Sentry DSN to enable error reporting -`ENABLE_LOGGING` | `undefined` | Enable verbose console output +Configuration is now handled with a yaml file in `./data/config.yaml` with the +following content: + +```yml +app: + emoji: ':headphones:' + separator: '·' + update_interval: 1 + update_weekends: false + update_hour_start: 8 + update_hour_end: 18 + +lastfm: + username: 'jckcthbrt' + api_key: '00000000000000000000000000000000' + shared_secret: '00000000000000000000000000000000' + +slack: + - user_id: 'U00000000' + token: 'xoxp-XXX-XXX-XXX-XXX' + - user_id: 'U00000000' + token: 'xoxp-XXX-XXX-XXX-XXX' +``` ## Hosting I designed this to be easily self hosted, just use the Docker image! It's automatically built and versioned on [Docker Hub](https://hub.docker.com/repository/docker/jckcthbrt/slack-fm/tags) based on GitHub activity. -### Docker run - -```bash -docker run \ - -e SLACK_TOKEN= \ - -e LAST_FM_KEY= \ - -e LAST_FM_USERNAME= \ - -e TZ= \ - jckcthbrt/slack-fm:latest -``` - ### Docker compose ```yml @@ -106,3 +101,29 @@ v12 and some environment variables. 1. Start the app locally with `npm start` 1. Commit and push your changes then submit a PR back to this repository +## V2 Slack App + +All documentation below here is for the v2 release of slack-fm which rebuilds it +into a proper Slack App. + +### Configuration + + +### Docker compose + +```yml +version: '3.7' +services: + slack_fm: + image: jckcthbrt/slack-fm:latest + container_name: slack_fm + restart: unless-stopped + environment: + TZ: + volumes: + - ./config.yaml:/app/data/config.yaml +``` + +```bash +docker-compose up +``` diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index e3d7f4c..0000000 --- a/src/config.ts +++ /dev/null @@ -1,49 +0,0 @@ -const { - LAST_FM_KEY = "", - LAST_FM_SECRET = "", - LAST_FM_USERNAME = "", - SLACK_TOKEN = "", - SLACK_EMOJI = ":headphones:", - SLACK_SEPARATOR = "•", - TZ = "Australia/Melbourne", - ACTIVE_HOURS_START = "8", - ACTIVE_HOURS_END = "18", - UPDATE_INTERVAL = "1", - UPDATE_EXPIRATION = "10", - UPDATE_WEEKENDS, - SENTRY_DSN, - ENABLE_LOGGING = "", -} = process.env; - -export const lastFM = { - apiUrl: "http://ws.audioscrobbler.com/2.0", - apiKey: LAST_FM_KEY, - apiSecret: LAST_FM_SECRET, - username: LAST_FM_USERNAME, -}; - -export const slack = { - apiUrl: "https://slack.com/api", - token: SLACK_TOKEN, - emoji: SLACK_EMOJI, - separator: SLACK_SEPARATOR, -}; - -export const activeHours = { - start: Number(ACTIVE_HOURS_START), - end: Number(ACTIVE_HOURS_END), -}; - -export const sentryDsn = SENTRY_DSN; - -/** Time in minutes to request new Last.fm data */ -export const updateInterval = Number(UPDATE_INTERVAL); -export const updateWeekends = !!UPDATE_WEEKENDS; - -/** Time in minutes to use as a default expiration time */ -export const updateExpiration = Number(UPDATE_EXPIRATION); - -/** Whether or not logging has been enabled */ -export const loggingEnabled = ENABLE_LOGGING !== ""; - -export const tz = TZ; diff --git a/src/index.ts b/src/index.ts index 22221ec..126311b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,113 +1,137 @@ -import { getTime, getHours, isWeekend } from "date-fns"; -import * as config from "./config"; -import { - getSlackPresence, - getSlackProfile, - setSlackStatus, - shouldSetStatus, -} from "./utils/slack"; -import { - getLastFmTrack, - getNowPlaying, - getRecentLastFmTracks, - trackIsEqual, -} from "./utils/lastFm"; -import { deleteTrackJSON, readTrackJSON, writeTrackJSON } from "./utils/cache"; -import { handleError, enableErrorTracking } from "./utils/errors"; -import { log } from "./utils/log"; -import { validateConfig } from "./utils/validation"; -import type { Track } from "./types/lastfm"; - -/** Clears the slack status if the cached track has no duration */ -async function clearSlackStatus(cached: Track | undefined) { - if (cached && cached.duration === "0") { - log("Cached track has no duration, clearing Slack status", "slack"); - await setSlackStatus(""); - } -} +import { getHours, getTime, isWeekend } from 'date-fns' +import { truncateStatus } from './lib' +import { Cache, Config, LastFM, Schedule, SlackAPI, log } from './services' +import type { Track } from './services/LastFM' -async function main() { - const cachedTrack = await readTrackJSON(); +const config = Config.load() - // Time restrictions - const currentTime = getTime(new Date()); - const currentHour = getHours(currentTime); +// API Clients +let lastFm: LastFM +let clients: SlackAPI[] - const { start, end } = config.activeHours; - if (currentHour < start || currentHour >= end) { - log(`Outside active hours (${start}-${end})`); - await clearSlackStatus(cachedTrack); - return; +async function setup(): Promise { + const { lastfm, slack } = Config.load() + + if (lastFm === undefined) { + lastFm = new LastFM(lastfm.username, lastfm.api_key) + log(`Configured Last.fm (${lastFm.username})`, 'bot') } - if (!config.updateWeekends && isWeekend(currentTime)) { - log("Weekend updates not enabled, skipping"); - await clearSlackStatus(cachedTrack); - return; + if (clients === undefined) { + clients = slack.map( + ({ user_id: userId, token }) => new SlackAPI(token, userId) + ) + log( + `Configured ${clients.length} Slack Team${clients.length > 1 ? 's' : ''}`, + 'bot' + ) } +} - // Status restrictions - const currentPresence = await getSlackPresence(); - if (currentPresence === "away") { - log('User presence is "away"'); - await clearSlackStatus(cachedTrack); - return; +async function clearOnEnded(cachedTrack?: Track): Promise { + if (cachedTrack === undefined) return + if (cachedTrack.duration !== undefined && cachedTrack.duration !== 0) return + + for (const client of clients) { + log('Cached track is missing or has no duration, clearing status', 'bot') + await client.setStatus('') } +} + +async function main(): Promise { + const cachedTrack = await Cache.readTrackJSON() + + // Time restrictions + // --- + const currentTime = getTime(new Date()) + const currentHour = getHours(currentTime) - const currentProfile = await getSlackProfile(); - if (!shouldSetStatus(currentProfile)) { - log("Custom status detected"); - return; + const { update_hour_start: start, update_hour_end: end } = config.app + if (currentHour < start || currentHour >= end) { + log(`Outside active hours (${start}-${end})`) + await clearOnEnded(cachedTrack) + return + } + + if (isWeekend(currentTime) && !config.app.update_weekends) { + log('Weekend updates not enabled, skipping') + await clearOnEnded(cachedTrack) + return } - // Now playing restrictions - const recentTracks = await getRecentLastFmTracks(config.lastFM.username); - const nowPlaying = getNowPlaying(recentTracks.track); + // Get recent track + // --- + const tracks = await lastFm.getRecentTracks() + const nowPlaying = tracks.find(t => t.nowPlaying) if (nowPlaying === undefined) { - log("Nothing playing"); - await clearSlackStatus(cachedTrack); - await deleteTrackJSON(); - return; + log('Nothing playing', 'bot') + await Cache.deleteTrackJSON() + await clearOnEnded(cachedTrack) + return } - // Equality restriction, don't update if it's not necessary - if (trackIsEqual(nowPlaying, cachedTrack)) { - log("Now playing track is cached, no update necessary"); - return; + // Don't update if the now playing track does not match the cached track, + // otherwise cache the new track + // --- + if ( + cachedTrack?.name.toLowerCase() === nowPlaying?.name.toLowerCase() && + cachedTrack?.artist.toLowerCase() === nowPlaying?.artist.toLowerCase() + ) { + log('Now playing track is cached, no update necessary', 'bot') + return + } else { + log('Caching track', 'bot') + console.log(nowPlaying) + await Cache.writeTrackJSON(nowPlaying) } - const track = await getLastFmTrack( - nowPlaying.name, - nowPlaying.artist["#text"] - ); - let duration = 60 * (config.updateExpiration * 1000); - let status = `${nowPlaying.name} ${config.slack.separator} ${nowPlaying.artist["#text"]}`; + // Attempt to find more details (duration) + // --- + const track = await lastFm.getTrack(nowPlaying.name, nowPlaying.artist) if (track !== undefined) { - await writeTrackJSON(track); - - status = `${track.name} ${config.slack.separator} ${track.artist.name}`; - duration = track.duration !== "0" ? Number(track.duration) : duration; - } else { - log("No detailed track info found, falling back to recent track", "lastfm"); - await deleteTrackJSON(); + log('Updating cached track', 'bot') + console.log(track) + await Cache.writeTrackJSON(track) } - log(`Setting status to "${status}"`, "slack"); - await setSlackStatus(status, duration); + // Compute new status and duration + // --- + const trackDuration = track?.duration ?? 600000 // 10 minutes + const trackName = track?.name ?? nowPlaying.name + const trackArtist = track?.artist ?? nowPlaying.artist + + const status = `${trackName} ${config.app.separator} ${trackArtist}` + + // Propagate updates to all Slack Teams + // --- + for (const client of clients) { + // Don't update if the user is away + const presence = await client.getPresence() + if (presence === 'away') continue + + // Don't update if the status wasn't previously set by slack-fm + const profile = await client.getProfile() + if ( + profile.status_text !== '' && + !profile.status_text.includes(config.app.separator) + ) + continue + + await client.setStatus( + truncateStatus(status, config.app.separator), + trackDuration + ) + } } -async function loop() { - const interval = config.updateInterval * 60000; - setInterval(async () => await main().catch(handleError), interval); -} +const interval = config.app.update_interval * 60000 +const schedule = new Schedule(main, interval) -validateConfig(config) - .then(enableErrorTracking) +setup() + .then(main) .then(() => { - log("slack-fm ready", "bot", true); + schedule.start() }) - .then(main) - .then(loop) - .catch(handleError); + .catch(console.error) diff --git a/src/lib/__tests__/truncateStatus.test.ts b/src/lib/__tests__/truncateStatus.test.ts new file mode 100644 index 0000000..1aec150 --- /dev/null +++ b/src/lib/__tests__/truncateStatus.test.ts @@ -0,0 +1,46 @@ +import { truncateStatus } from '../truncateStatus' + +const sep = '·' +const hellip = '…' + +describe('truncateStatus', () => { + it('returns a short status unaltered', () => { + expect(truncateStatus(`Hello ${sep} Yes`, sep)).toBe(`Hello ${sep} Yes`) + }) + + it('returns a long track name truncated', () => { + const longBoi = + 'One Day the Only Butterflies Left Will Be in Your Chest as You March Towards Your Death · Bring me the Horizon' + const result = truncateStatus(longBoi, sep) + + const truncName = `One Day the Only Butterflies Left Will Be in Your Chest as You March Towards${hellip}` + const truncArtist = 'Bring me the Horizon' + + expect(result).toEqual(`${truncName} ${sep} ${truncArtist}`) + expect(result.length).toBeLessThanOrEqual(100) + }) + + it('returns a long artist name truncated', () => { + const longBoi = + 'Bring me the Horizon · One Day the Only Butterflies Left Will Be in Your Chest as You March Towards Your Death' + const result = truncateStatus(longBoi, sep) + + const truncName = 'Bring me the Horizon' + const truncArtist = `One Day the Only Butterflies Left Will Be in Your Chest as You March Towards${hellip}` + + expect(result).toEqual(`${truncName} ${sep} ${truncArtist}`) + expect(result.length).toEqual(100) + }) + + it('returns long track and artist names truncated', () => { + const longBoi = + 'One Day the Only Butterflies Left Will Be in Your Chest as You March Towards Your Death · One Day the Only Butterflies Left Will Be in Your Chest as You March Towards Your Death' + const result = truncateStatus(longBoi, sep) + + const truncName = `One Day the Only Butterflies Left Will Be in Yo${hellip}` + const truncArtist = `One Day the Only Butterflies Left Will Be in Yo${hellip}` + + expect(result).toEqual(`${truncName} ${sep} ${truncArtist}`) + expect(result.length).toBeLessThanOrEqual(100) + }) +}) diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..be04bee --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1 @@ +export * from './truncateStatus' diff --git a/src/lib/truncateStatus.ts b/src/lib/truncateStatus.ts new file mode 100644 index 0000000..f0f180a --- /dev/null +++ b/src/lib/truncateStatus.ts @@ -0,0 +1,40 @@ +const MAX_LENGTH = 100 + +export function truncateStatus(status: string, sepChar: string): string { + if (status.length <= MAX_LENGTH) return status + + const hellip = '…' + const separator = ` ${sepChar} ` + + const [name, artist] = status.split(separator) + + let newTrackName = name + let newArtistName = artist + + // truncate track name + if (name.length > 48 && artist.length <= 48) { + const availNumChars = + MAX_LENGTH - artist.length - separator.length - hellip.length + + newTrackName = name.slice(0, availNumChars).trim() + hellip + } + + // truncate artist name + else if (name.length <= 48 && artist.length > 48) { + const availNumChars = + MAX_LENGTH - name.length - separator.length - hellip.length + + newArtistName = artist.slice(0, availNumChars).trim() + hellip + } + + // truncate both + else if (artist.length > 48 && artist.length > 48) { + const availNumChars = + (MAX_LENGTH - separator.length - hellip.length * 2) / 2 + + newTrackName = name.slice(0, availNumChars).trim() + hellip + newArtistName = artist.slice(0, availNumChars).trim() + hellip + } + + return `${newTrackName} ${sepChar} ${newArtistName}` +} diff --git a/src/services/Cache.ts b/src/services/Cache.ts new file mode 100644 index 0000000..449be67 --- /dev/null +++ b/src/services/Cache.ts @@ -0,0 +1,30 @@ +import * as path from 'path' +import * as fs from 'fs-extra' +import type { RecentTrack, Track } from '../services/LastFM' + +const cachePath = path.resolve(__dirname, '..', '..', 'data', 'cache.json') + +export async function writeTrackJSON(obj?: Track): Promise { + if (obj === undefined) return undefined + return await fs.writeJSON(cachePath, obj) +} + +export async function readTrackJSON(): Promise< + Track | RecentTrack | undefined +> { + try { + const track: Track = await fs.readJSON(cachePath) + return track + } catch (error) { + // this is fine, I promise + return undefined + } +} + +export async function deleteTrackJSON(): Promise { + try { + await fs.remove(cachePath) + } catch (error) { + // this is okay + } +} diff --git a/src/services/Config.ts b/src/services/Config.ts new file mode 100644 index 0000000..88c8ff2 --- /dev/null +++ b/src/services/Config.ts @@ -0,0 +1,82 @@ +import Joi from 'joi' +import * as path from 'path' +import * as fs from 'fs' +import yaml from 'js-yaml' + +declare global { + var APP_CONFIG: Config | undefined +} + +interface AppConfig { + emoji: string + separator: string + update_interval: number + update_weekends: boolean + update_hour_start: number + update_hour_end: number +} + +interface LastFmConfig { + username: string + api_key: string + shared_secret: string +} + +interface SlackWorkspaceConfig { + user_id: string + token: string +} + +export interface Config { + app: AppConfig + lastfm: LastFmConfig + slack: SlackWorkspaceConfig[] +} + +const configPath = path.resolve(__dirname, '..', '..', 'data', 'config.yaml') + +function validate(cfg: any): Joi.ValidationResult { + const schema = Joi.object({ + app: Joi.object({ + emoji: Joi.string() + .required() + .regex(/^:[^:]+:$/i), + separator: Joi.string().required().min(1).max(1), + update_interval: Joi.number().required().positive(), + update_weekends: Joi.boolean().required(), + update_hour_start: Joi.number().required().min(0).max(23), + update_hour_end: Joi.number().required().min(0).max(23) + }).required(), + lastfm: Joi.object({ + username: Joi.string().required(), + api_key: Joi.string().required(), + shared_secret: Joi.string().required() + }).required(), + slack: Joi.array() + .items( + Joi.object({ + user_id: Joi.string().required(), + token: Joi.string().required() + }) + ) + .required() + .min(1) + }) + + return schema.validate(cfg) +} + +export function load(): Config { + if (global.APP_CONFIG === undefined) { + const config = yaml.load(fs.readFileSync(configPath, 'utf8')) as Config + const { value, error } = validate(config) + + if (error !== undefined) { + throw error + } + + global.APP_CONFIG = value as Config + } + + return global.APP_CONFIG +} diff --git a/src/services/LastFM.ts b/src/services/LastFM.ts new file mode 100644 index 0000000..c701e21 --- /dev/null +++ b/src/services/LastFM.ts @@ -0,0 +1,113 @@ +import env from 'env-var' +import axios from 'axios' +import { log } from './Logger' +import type { + APIUserGetRecentTracks, + APIRecentTrack, + APITrackGetInfo, + APITrack +} from '../types/lastfm' + +export interface RecentTrack { + name: string + artist: string + nowPlaying?: boolean +} + +export interface Track { + name: string + artist: string + duration?: number +} + +function toRecentTrack(rt: APIRecentTrack): RecentTrack { + return { + name: rt.name, + artist: rt.artist['#text'], + nowPlaying: rt['@attr']?.nowplaying === 'true' + } +} + +function toTrack(t: APITrack): Track { + return { + name: t.name, + artist: t.artist.name, + duration: + t.duration === '0' || t.duration === undefined + ? undefined + : Number(t.duration) + } +} + +export class LastFM { + public readonly username: string + private readonly apiUrl: string + private readonly apiKey: string + + constructor(username: string, apiKey: string) { + this.username = username + this.apiKey = apiKey + + this.apiUrl = env + .get('LASTFM_API_URL') + .default('http://ws.audioscrobbler.com/2.0') + .asString() + } + + /** + * Get the most recent tracks from a users LastFM profile + * + * [API Doc](https://www.last.fm/api/show/user.getRecentTracks) + */ + async getRecentTracks(): Promise { + log(`Getting recent track info for "${this.username}"`, 'lastfm') + + const url = `${this.apiUrl}/?method=user.getrecenttracks` + const opts = { + params: { + format: 'json', + api_key: this.apiKey, + user: this.username, + limit: 1 + } + } + + try { + const { data } = await axios.get(url, opts) + return data.recenttracks.track.map(toRecentTrack) + } catch (error) { + throw error.response !== undefined + ? Error(error.response.data.message) + : error + } + } + + /** + * Get the detailed track info given a track name and artist + * + * [API Doc](https://www.last.fm/api/show/track.getInfo) + */ + async getTrack(track: string, artist: string): Promise { + log(`Getting track info for "${track}" by ${artist}`, 'lastfm') + + const url = `${this.apiUrl}/?method=track.getInfo` + const opts = { + params: { + artist, + track, + format: 'json', + api_key: this.apiKey, + limit: 1 + } + } + + try { + const { data } = await axios.get(url, opts) + return data.track !== undefined ? toTrack(data.track) : undefined + } catch (error) { + throw error.response !== undefined + ? Error(error.response.data.message) + : error + } + } +} diff --git a/src/services/Logger.ts b/src/services/Logger.ts new file mode 100644 index 0000000..e202c3a --- /dev/null +++ b/src/services/Logger.ts @@ -0,0 +1,18 @@ +import env from 'env-var' +import chalk from 'chalk' + +const contexts = { + lastfm: chalk.red('[LastFM]'), + slack: chalk.greenBright('[Slack]'), + bot: chalk.blue('[Bot]') +} + +type LogContext = keyof typeof contexts + +export function log(message: string, ctx: LogContext = 'bot'): void { + const loggingEnabled = env.get('ENABLE_LOGGING').default('false').asBool() + + if (loggingEnabled) { + console.log(`${contexts[ctx]} ${message}`) + } +} diff --git a/src/services/Schedule.ts b/src/services/Schedule.ts new file mode 100644 index 0000000..05559df --- /dev/null +++ b/src/services/Schedule.ts @@ -0,0 +1,32 @@ +import { EventEmitter } from 'events' + +type CB = () => Promise + +export class Schedule extends EventEmitter { + action: CB + handle?: NodeJS.Timer + interval: number + + constructor(action: CB, ms: number) { + super() + this.action = action + this.handle = undefined + this.interval = ms + this.addListener('timeout', () => { + this.action().catch(console.error) + }) + } + + public start(): void { + if (this.handle === undefined) { + this.handle = setInterval(() => this.emit('timeout'), this.interval) + } + } + + public stop(): void { + if (this.handle !== undefined) { + clearInterval(this.handle) + this.handle = undefined + } + } +} diff --git a/src/services/SlackAPI.ts b/src/services/SlackAPI.ts new file mode 100644 index 0000000..ce3550b --- /dev/null +++ b/src/services/SlackAPI.ts @@ -0,0 +1,89 @@ +import { WebClient } from '@slack/web-api' +import { getUnixTime } from 'date-fns' +import { Config } from '.' +import { log } from './Logger' +import type { Profile, Presence } from '../types/slack' + +/** + * Add the duration returned from LastFM (ms) to the current unix time + * + * @param duration Duration of song in milliseconds + */ +function calcExpiration(duration: number): number { + return getUnixTime(new Date()) + duration / 1000 +} + +function obfuscateToken(token: string): string { + if (token === '') return token + + const first = token.slice(0, 4) + const last = token.slice(-5, token.length + 1) + + return `${first}-xxxxx-${last}` +} + +export class SlackAPI { + public readonly userId: string + private readonly client: WebClient + + constructor(token: string, userId: string) { + this.client = new WebClient(token) + this.userId = userId + } + + async getProfile(): Promise { + const safeToken = obfuscateToken(this.client.token ?? '') + + log(`users.profile.get (${safeToken})`, 'slack') + + const user = await this.client.users.profile.get({ user: this.userId }) + if (!user.ok) throw Error(user.error) + + const profile = user.profile as Profile + return profile + } + + async getPresence(): Promise { + const safeToken = obfuscateToken(this.client.token ?? '') + + log(`users.getPresence (${safeToken})`, 'slack') + const result = await this.client.users.getPresence({ + user: this.userId + }) + + if (!result.ok) throw Error(result.error) + + return result.presence as Presence + } + + /** + * @param status + * @param duration Track runtime in milliseconds + */ + async setStatus(status: string, duration?: number): Promise { + const config = Config.load() + const safeToken = obfuscateToken(this.client.token ?? '') + + const payload = { + status_text: status, + status_emoji: status !== '' ? config.app?.emoji ?? ':headphones:' : '', + status_expiration: + status !== '' + ? duration !== undefined + ? calcExpiration(duration) + : 0 + : 0 + } + + log(`users.profile.set (${safeToken}): ${JSON.stringify(payload)}`, 'slack') + + const result = await this.client.users.profile.set({ + user: this.userId, + profile: JSON.stringify(payload) + }) + + if (!result.ok) throw Error(result.error) + + return result.profile + } +} diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..7b61a02 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,6 @@ +export * as Cache from './Cache' +export * as Config from './Config' +export { LastFM } from './LastFM' +export { Schedule } from './Schedule' +export { SlackAPI } from './SlackAPI' +export { log } from './Logger' diff --git a/src/types/lastfm.ts b/src/types/lastfm.ts index 2b1857a..1b91a79 100644 --- a/src/types/lastfm.ts +++ b/src/types/lastfm.ts @@ -1,76 +1,76 @@ -interface Image { - size: "small" | "medium" | "large" | "extralarge"; +interface APIImage { + size: 'small' | 'medium' | 'large' | 'extralarge' /** URL */ - "#text": string; + '#text': string } -export interface RecentTrack { +export interface APIRecentTrack { artist: { - mbid: string; - "#text": string; - }; - "@attr"?: { - nowplaying: "true" | "false"; - }; + mbid: string + '#text': string + } + '@attr'?: { + nowplaying: 'true' | 'false' + } album: { - mbid: string; - "#text": string; - }; - image: Image[]; - streamable: string; + mbid: string + '#text': string + } + image: APIImage[] + streamable: string date: { - uts: string; - "#text": string; - }; - url: string; - name: string; - mbid: string; + uts: string + '#text': string + } + url: string + name: string + mbid: string } -export interface Tag { - name: string; - url: string; +export interface APITag { + name: string + url: string } -export interface Track { - name: string; - url: string; - duration: string; +export interface APITrack { + name: string + url: string + duration: string streamable: { - "#text": string; - fulltrack: string; - }; - listeners: string; - playcount: string; + '#text': string + fulltrack: string + } + listeners: string + playcount: string artist: { - name: string; - mbid: string; - url: string; - }; + name: string + mbid: string + url: string + } album: { - artist: string; - title: string; - url: string; - image: Image[]; - }; + artist: string + title: string + url: string + image: APIImage[] + } toptags: { - tag: Tag[]; - }; + tag: APITag[] + } } export interface APIUserGetRecentTracks { recenttracks: { - "@attr": { - page: string; - total: string; - user: string; - perPage: string; - totalPages: string; - }; - track: RecentTrack[]; - }; + '@attr': { + page: string + total: string + user: string + perPage: string + totalPages: string + } + track: APIRecentTrack[] + } } export interface APITrackGetInfo { - track: Track; + track?: APITrack } diff --git a/src/types/slack.ts b/src/types/slack.ts index fcae691..cd92451 100644 --- a/src/types/slack.ts +++ b/src/types/slack.ts @@ -1,33 +1,29 @@ -/* eslint-disable camelcase */ -export type Presence = "active" | "away"; +export type Presence = 'active' | 'away' export interface Profile { - avatar_hash: string; - status_text: string; - status_emoji: string; - status_expiration: number; - real_name: string; - display_name: string; - real_name_normalized: string; - display_name_normalized: string; - email: string; - image_24: string; - image_32: string; - image_48: string; - image_72: string; - image_192: string; - image_512: string; - team: string; -} - -export interface APIUsersProfile { - ok: boolean; - error?: string; - profile: Profile; -} - -export interface APIUsersGetPresence { - ok: boolean; - error?: string; - presence: Presence; + title: string + phone: string + skype: string + real_name: string + real_name_normalized: string + display_name: string + display_name_normalized: string + fields: any[] + status_text: string + status_emoji: string + status_expiration: number + avatar_hash: string + image_original: string + is_custom_image: boolean + email: string + first_name: string + last_name: string + image_24: string + image_32: string + image_48: string + image_72: string + image_192: string + image_512: string + image_1024: string + status_text_canonical: string } diff --git a/src/utils/__tests__/slack.test.ts b/src/utils/__tests__/slack.test.ts deleted file mode 100644 index acdb5f5..0000000 --- a/src/utils/__tests__/slack.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { shouldSetStatus, calcExpiration } from "../slack"; -import { getUnixTime } from "date-fns"; -import type { Profile } from "../../types/slack"; - -describe("slack", () => { - describe("shouldSetStatus", () => { - it("returns true when the app has previously updated the profile", () => { - const profile: Partial = { - status_emoji: ":headphones:", - status_text: "Some song • Some artist", - }; - const result = shouldSetStatus(profile as any); - expect(result).toBeTruthy(); - }); - - it("returns true when no status is set", () => { - const profile: Partial = { - status_emoji: "", - status_text: "", - }; - const result = shouldSetStatus(profile as any); - expect(result).toBeTruthy(); - }); - - it("returns false when a custom status is set", () => { - const profile: Partial = { - status_emoji: ":troll:", - status_text: "Doing other things", - }; - const result = shouldSetStatus(profile as any); - expect(result).toBeFalsy(); - }); - }); - - describe("calcExpiration", () => { - it("adds the duration to the current time", () => { - const result = calcExpiration(5 * 60 * 1000); // 5 min in ms - const now = getUnixTime(new Date()); - - expect(result).toBeGreaterThan(now); - expect(result).toBe(now + 300); // adds 300s/5min to current unix time - }); - }); -}); diff --git a/src/utils/__tests__/utils.test.ts b/src/utils/__tests__/utils.test.ts deleted file mode 100644 index 5eab883..0000000 --- a/src/utils/__tests__/utils.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { getNowPlaying } from "../lastFm"; -import type { RecentTrack } from "../../types/lastfm"; - -describe("lastfm", () => { - describe("getNowPlaying", () => { - it("returns a now playing track", () => { - const track: Partial = { - "@attr": { - nowplaying: "true", - }, - }; - - expect(getNowPlaying([track as any])).toEqual(track); - }); - - it("returns undefined when nowplaying = false", () => { - const track: Partial = { - "@attr": { - nowplaying: "false", - }, - }; - - expect(getNowPlaying([track as any])).toBeUndefined(); - }); - - it("returns undefined when @attr.nowplaying is undefined", () => { - const track: Partial = { - artist: { - mbid: "", - "#text": "", - }, - }; - - expect(getNowPlaying([track as any])).toBeUndefined(); - }); - }); -}); diff --git a/src/utils/cache.ts b/src/utils/cache.ts deleted file mode 100644 index 02ef9f6..0000000 --- a/src/utils/cache.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as path from "path"; -import * as fs from "fs-extra"; -import type { Track } from "../types/lastfm"; - -const cachePath = path.join(".", ".track.json"); - -export async function writeTrackJSON(obj?: Track) { - if (obj === undefined) return undefined; - return await fs.writeJSON(cachePath, obj); -} - -export async function readTrackJSON(): Promise { - try { - const track: Track = await fs.readJSON(cachePath); - return track; - } catch (error) { - // this is fine, I promise - return undefined; - } -} - -export async function deleteTrackJSON() { - try { - await fs.remove(cachePath); - } catch (error) { - // this is okay - } -} diff --git a/src/utils/errors.ts b/src/utils/errors.ts deleted file mode 100644 index 1c0e46d..0000000 --- a/src/utils/errors.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as Sentry from "@sentry/node"; -import * as config from "../config"; -import { log } from "./log"; - -/** Generic error handler for async function failures */ -export function handleError(error: Error) { - console.error(error); - Sentry.captureException(error); -} - -/** If a Sentry DSN is supplied, enable error tracking */ -export function enableErrorTracking() { - if (config.sentryDsn !== undefined) { - log("Sentry error reporting enabled"); - Sentry.init({ - dsn: config.sentryDsn, - attachStacktrace: true, - }); - } -} diff --git a/src/utils/lastFm.ts b/src/utils/lastFm.ts deleted file mode 100644 index 8d81445..0000000 --- a/src/utils/lastFm.ts +++ /dev/null @@ -1,82 +0,0 @@ -import axios, { AxiosResponse } from "axios"; -import * as config from "../config"; -import { log } from "./log"; -import type { - APITrackGetInfo, - APIUserGetRecentTracks, - RecentTrack, - Track, -} from "../types/lastfm"; - -/** - * Get the most recent tracks from a users LastFM profile - * - * [API Doc](https://www.last.fm/api/show/user.getRecentTracks) - */ -export async function getRecentLastFmTracks(username: string) { - log(`Getting recent track info for "${config.lastFM.username}"`, "lastfm"); - - type LastFMResponse = AxiosResponse; - const url = `${config.lastFM.apiUrl}/?method=user.getrecenttracks`; - const opts = { - params: { - format: "json", - api_key: config.lastFM.apiKey, - user: username, - limit: 1, - }, - }; - - try { - const { data }: LastFMResponse = await axios.get(url, opts); - return data.recenttracks; - } catch (error) { - if (error.response) throw Error(error.response.data.message); - throw error; - } -} - -/** - * Get the detailed track info given a track name and artist - * - * [API Doc](https://www.last.fm/api/show/track.getInfo) - */ -export async function getLastFmTrack( - track: string, - artist: string -): Promise { - log(`Getting track info for "${track}" by ${artist}`, "lastfm"); - - type LastFMResponse = AxiosResponse; - const url = `${config.lastFM.apiUrl}/?method=track.getInfo`; - const opts = { - params: { - artist, - track, - format: "json", - api_key: config.lastFM.apiKey, - limit: 1, - }, - }; - - try { - const { data }: LastFMResponse = await axios.get(url, opts); - return data.track; - } catch (error) { - if (error.response) throw Error(error.response.data.message); - throw error; - } -} - -/** Returns a LastFM track if it's considered now playing */ -export function getNowPlaying(tracks: RecentTrack[]) { - return tracks.find((track) => track["@attr"]?.nowplaying === "true"); -} - -/** Determines if the recent track is equal to the cached track */ -export function trackIsEqual(recent: RecentTrack, cached?: Track) { - if (cached === undefined) return false; - return ( - recent.name === cached.name && recent.artist["#text"] === cached.artist.name - ); -} diff --git a/src/utils/log.ts b/src/utils/log.ts deleted file mode 100644 index 12ef180..0000000 --- a/src/utils/log.ts +++ /dev/null @@ -1,16 +0,0 @@ -import chalk from "chalk"; -import { loggingEnabled } from "../config"; - -const contexts = { - lastfm: chalk.red("[LastFM]"), - slack: chalk.greenBright("[Slack]"), - bot: chalk.blue("[Bot]"), -}; - -type LogContext = keyof typeof contexts; - -export function log(message: string, ctx: LogContext = "bot", force = false) { - if (loggingEnabled || force) { - console.log(`${contexts[ctx]} ${message}`); - } -} diff --git a/src/utils/slack.ts b/src/utils/slack.ts deleted file mode 100644 index 3dd15d2..0000000 --- a/src/utils/slack.ts +++ /dev/null @@ -1,122 +0,0 @@ -import axios, { AxiosResponse } from "axios"; -import { getUnixTime } from "date-fns"; -import * as config from "../config"; -import { log } from "./log"; -import type { - APIUsersGetPresence, - APIUsersProfile, - Presence, - Profile, -} from "../types/slack"; - -/** - * Add the duration returned from LastFM (ms) to the current unix time - * - * @param duration Duration of song in milliseconds - */ -export function calcExpiration(duration: number) { - return getUnixTime(new Date()) + duration / 1000; -} - -/** - * Set a users status on slack with some message and emoji - * - * [API Doc](https://api.slack.com/methods/users.profile.set) - * - * @param status The status text to set - * @param duration The time in milliseconds to keep the state - * - */ -export async function setSlackStatus(status: string, duration?: number) { - type SlackResponse = AxiosResponse; - const url = `${config.slack.apiUrl}/users.profile.set`; - - const body = { - profile: { - status_text: status, - status_emoji: status !== "" ? config.slack.emoji : "", - status_expiration: duration !== undefined ? calcExpiration(duration) : 0, - }, - }; - - const params = { - headers: { Authorization: `Bearer ${config.slack.token}` }, - }; - - try { - const { data }: SlackResponse = await axios.post(url, body, params); - if (!data.ok) throw Error(data.error); - return data; - } catch (error) { - if (error.response) throw Error(error.response.data.message); - throw error; - } -} - -/** - * Return the current presence of the authenticated user - * - * [API Doc](https://api.slack.com/methods/users.getPresence) - */ -export async function getSlackPresence(): Promise { - log("Getting user presence", "slack"); - - type SlackResponse = AxiosResponse; - const url = `${config.slack.apiUrl}/users.getPresence`; - const params = { - headers: { Authorization: `Bearer ${config.slack.token}` }, - }; - - try { - const { data }: SlackResponse = await axios.get(url, params); - if (!data.ok) throw Error(data.error); - return data.presence; - } catch (error) { - if (error.response) throw Error(error.response.data.message); - throw error; - } -} - -/** - * Return the current profile (including status) of the authenticated user - * - * [API Doc](https://api.slack.com/methods/users.profile.get) - */ -export async function getSlackProfile(): Promise { - log("Getting user profile", "slack"); - - type SlackResponse = AxiosResponse; - const url = `${config.slack.apiUrl}/users.profile.get`; - const opts = { - headers: { Authorization: `Bearer ${config.slack.token}` }, - }; - - try { - const { data }: SlackResponse = await axios.get(url, opts); - if (!data.ok) throw Error(data.error); - return data.profile; - } catch (error) { - if (error.response) throw Error(error.response.data.message); - throw error; - } -} - -/** - * Returns true if the profile should be updated. - * - * It assumes that if a status is using the configured emoji and contains a - * middle dot character (`•`) that the app has previously been used to update - * the status and should continue to. - * - * This ensures that any custom status the user has set is not overridden and - * empty statuses are updated accordingly. - */ -export function shouldSetStatus(profile: Profile) { - if (profile.status_emoji === "" && profile.status_text === "") return true; - if ( - profile.status_emoji === config.slack.emoji && - profile.status_text.includes(" • ") - ) - return true; - return false; -} diff --git a/src/utils/validation.ts b/src/utils/validation.ts deleted file mode 100644 index e968ad3..0000000 --- a/src/utils/validation.ts +++ /dev/null @@ -1,61 +0,0 @@ -import Joi from "@hapi/joi"; -import * as config from "../config"; - -/** Validate the user-configurable options in config */ -export async function validateConfig(cfg: typeof config) { - const schema = Joi.object({ - slackToken: Joi.string().min(1).required().messages({ - "string.base": "SLACK_TOKEN must be defined", - "string.min": "SLACK_TOKEN must be defined", - "string.empty": "SLACK_TOKEN must be defined", - }), - lastFMKey: Joi.string().min(1).required().messages({ - "string.base": "LAST_FM_KEY must be defined", - "string.min": "LAST_FM_KEY must be defined", - "string.empty": "LAST_FM_KEY must be defined", - }), - lastFMUsername: Joi.string().min(1).required().messages({ - "string.base": "LAST_FM_USERNAME must be defined", - "string.min": "LAST_FM_USERNAME must be defined", - "string.empty": "LAST_FM_USERNAME must be defined", - }), - activeHours: Joi.object({ - start: Joi.number() - .min(0) - .max(23) - .custom((v, helpers) => - v > cfg.activeHours.end ? helpers.error("startafterend") : null - ) - .required() - .messages({ - "number.base": "ACTIVE_HOURS_START must be a number", - "number.min": "ACTIVE_HOURS_START must be equal to or greater than 0", - "number.max": "ACTIVE_HOURS_START must be equal to or less than 23", - startafterend: "ACTIVE_HOURS_START must be before ACTIVE_HOURS_END", - }), - end: Joi.number() - .min(0) - .max(23) - .custom((v, helpers) => - v < cfg.activeHours.start ? helpers.error("startbeforeend") : null - ) - .required() - .messages({ - "number.base": "ACTIVE_HOURS_END must be a number", - "number.min": "ACTIVE_HOURS_END must be equal to or greater than 0", - "number.max": "ACTIVE_HOURS_END must be equal to or less than 23", - startbeforeend: "ACTIVE_HOURS_END must be after ACTIVE_HOURS_START", - }), - }).required(), - }); - - return schema.validateAsync({ - slackToken: cfg.slack.token, - lastFMKey: cfg.lastFM.apiKey, - lastFMUsername: cfg.lastFM.username, - activeHours: { - start: cfg.activeHours.start, - end: cfg.activeHours.end, - }, - }); -} diff --git a/tsconfig.json b/tsconfig.json index 573ae3a..bf47136 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,8 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true } }