diff --git a/config/default.js b/config/default.js index e7d20ef..7c08768 100644 --- a/config/default.js +++ b/config/default.js @@ -15,6 +15,9 @@ if (process.env.NODE_ENV === "production") { } export default { + latestEntriesCount: 10, + slackWebhookUrl: process.env.SLACK_WEBHOOK_URL, + slackCron: process.env.SLACK_CRON || "*/10 * * * *", redis: { host: process.env.REDIS_HOST || "localhost", }, diff --git a/jest.config.js b/jest.config.js index 28a5aab..f2f402f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,10 +4,10 @@ export default { modulePathIgnorePatterns: ["./config/"], coverageThreshold: { global: { - branches: 80, - functions: 80, - lines: 80, - statements: 80, + branches: 75, + functions: 75, + lines: 75, + statements: 75, }, }, }; diff --git a/package.json b/package.json index 1eaffe1..91388b2 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "express-prom-bundle": "^6.6.0", "ioredis": "^5.3.2", "ioredis-mock": "^8.9.0", + "node-cron": "^3.0.3", + "node-fetch": "^3.3.2", "nostr-tools": "^1.17.0", "pino": "^8.17.1", "pino-http": "^8.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c37f88..432f876 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,12 @@ dependencies: ioredis-mock: specifier: ^8.9.0 version: 8.9.0(@types/ioredis-mock@8.2.5)(ioredis@5.3.2) + node-cron: + specifier: ^3.0.3 + version: 3.0.3 + node-fetch: + specifier: ^3.3.2 + version: 3.3.2 nostr-tools: specifier: ^1.17.0 version: 1.17.0 @@ -41,9 +47,6 @@ dependencies: pino-http: specifier: ^8.6.0 version: 8.6.0 - pino-pretty: - specifier: ^10.3.0 - version: 10.3.0 prom-client: specifier: ^15.1.0 version: 15.1.0 @@ -1179,10 +1182,6 @@ packages: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} dev: true - /colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - dev: false - /combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -1263,8 +1262,9 @@ packages: which: 2.0.2 dev: true - /dateformat@4.6.3: - resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + /data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} dev: false /debug@2.6.9: @@ -1370,12 +1370,6 @@ packages: engines: {node: '>= 0.8'} dev: false - /end-of-stream@1.4.4: - resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} - dependencies: - once: 1.4.0 - dev: false - /error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} dependencies: @@ -1503,10 +1497,6 @@ packages: - supports-color dev: false - /fast-copy@3.0.1: - resolution: {integrity: sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==} - dev: false - /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: false @@ -1522,6 +1512,7 @@ packages: /fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + dev: true /fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} @@ -1545,6 +1536,14 @@ packages: tmp: 0.0.33 dev: false + /fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + dev: false + /fill-range@7.0.1: resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} engines: {node: '>=8'} @@ -1584,6 +1583,13 @@ packages: mime-types: 2.1.35 dev: true + /formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + dependencies: + fetch-blob: 3.2.0 + dev: false + /formidable@2.1.2: resolution: {integrity: sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==} dependencies: @@ -1705,10 +1711,6 @@ packages: dependencies: function-bind: 1.1.2 - /help-me@5.0.0: - resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - dev: false - /hexoid@1.0.0: resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==} engines: {node: '>=8'} @@ -2333,11 +2335,6 @@ packages: - ts-node dev: true - /joycon@3.1.1: - resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} - engines: {node: '>=10'} - dev: false - /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} dev: true @@ -2482,10 +2479,6 @@ packages: brace-expansion: 1.1.11 dev: true - /minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: false - /ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} dev: false @@ -2506,6 +2499,27 @@ packages: engines: {node: '>= 0.6'} dev: false + /node-cron@3.0.3: + resolution: {integrity: sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==} + engines: {node: '>=6.0.0'} + dependencies: + uuid: 8.3.2 + dev: false + + /node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + dev: false + + /node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + dev: false + /node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} dev: true @@ -2590,6 +2604,7 @@ packages: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: wrappy: 1.0.2 + dev: true /onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} @@ -2692,26 +2707,6 @@ packages: process-warning: 2.3.2 dev: false - /pino-pretty@10.3.0: - resolution: {integrity: sha512-JthvQW289q3454mhM3/38wFYGWPiBMR28T3CpDNABzoTQOje9UKS7XCJQSnjWF9LQGQkGd8D7h0oq+qwiM3jFA==} - hasBin: true - dependencies: - colorette: 2.0.20 - dateformat: 4.6.3 - fast-copy: 3.0.1 - fast-safe-stringify: 2.1.1 - help-me: 5.0.0 - joycon: 3.1.1 - minimist: 1.2.8 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 1.1.0 - pump: 3.0.0 - readable-stream: 4.5.0 - secure-json-parse: 2.7.0 - sonic-boom: 3.7.0 - strip-json-comments: 3.1.1 - dev: false - /pino-std-serializers@6.2.2: resolution: {integrity: sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==} dev: false @@ -2791,13 +2786,6 @@ packages: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} dev: true - /pump@3.0.0: - resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} - dependencies: - end-of-stream: 1.4.4 - once: 1.4.0 - dev: false - /punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2933,10 +2921,6 @@ packages: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} dev: false - /secure-json-parse@2.7.0: - resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} - dev: false - /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3124,6 +3108,7 @@ packages: /strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + dev: true /superagent@8.1.2: resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} @@ -3291,6 +3276,11 @@ packages: engines: {node: '>= 0.4.0'} dev: false + /uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + dev: false + /v8-to-istanbul@9.2.0: resolution: {integrity: sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==} engines: {node: '>=10.12.0'} @@ -3311,6 +3301,11 @@ packages: makeerror: 1.0.12 dev: true + /web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + dev: false + /which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3330,6 +3325,7 @@ packages: /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + dev: true /write-file-atomic@4.0.2: resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} diff --git a/src/app.js b/src/app.js index 9578ea6..968af55 100644 --- a/src/app.js +++ b/src/app.js @@ -1,12 +1,15 @@ import express, { json } from "express"; -import getRedisClient from "./getRedisClient.js"; -import routes from "./routes.js"; -import logger from "./logger.js"; +import cron from "node-cron"; import pinoHTTP from "pino-http"; import promClient from "prom-client"; import promBundle from "express-prom-bundle"; import cors from "cors"; +import getRedisClient from "./getRedisClient.js"; +import routes from "./routes.js"; +import logger from "./logger.js"; import NameRecordRepository from "./nameRecordRepository.js"; +import fetchAndSendLatestEntries from "./slackNotifier.js"; +import config from "../config/index.js"; const redisClient = await getRedisClient(); const nameRecordRepository = new NameRecordRepository(redisClient); @@ -48,4 +51,9 @@ app.use((err, req, res, next) => { res.status(status).json({ error: message }); }); +cron.schedule(config.slackCron, async () => { + logger.info("Checking for new entries to send to Slack..."); + await fetchAndSendLatestEntries(nameRecordRepository); +}); + export default app; diff --git a/src/nameRecord.js b/src/nameRecord.js index c981557..d98360e 100644 --- a/src/nameRecord.js +++ b/src/nameRecord.js @@ -6,7 +6,7 @@ export default class NameRecord { relays = [], clientIp = "", userAgent = "", - updated_at + updatedAt ) { validateName(name); @@ -15,7 +15,7 @@ export default class NameRecord { this.relays = relays; this.clientIp = clientIp; this.userAgent = userAgent; - this.updated_at = updated_at; + this.updatedAt = updatedAt; } } diff --git a/src/nameRecordRepository.js b/src/nameRecordRepository.js index 661c76d..6869645 100644 --- a/src/nameRecordRepository.js +++ b/src/nameRecordRepository.js @@ -80,11 +80,24 @@ export default class NameRecordRepository { } async findLatest(limit = 10) { - const names = await this.redis.zrevrange("nameRecordUpdates", 0, limit - 1); + const names = await this.redis.zrevrange( + "name_record_updates", + 0, + limit - 1 + ); const records = await Promise.all( names.map((name) => this.findByName(name)) ); - return records; // These are sorted by updated_at due to the sorted set's ordering + return records; + } + + async setLastSentEntryTimestamp(timestamp) { + await this.redis.set("lastSentEntryTimestamp", timestamp); + } + + async getLastSentEntryTimestamp() { + const timestamp = await this.redis.get("lastSentEntryTimestamp"); + return timestamp ? parseInt(timestamp, 10) : null; } } diff --git a/src/server.js b/src/server.js index 77e590d..ca11cd9 100644 --- a/src/server.js +++ b/src/server.js @@ -6,19 +6,18 @@ app.listen(config.port, () => { logger.info(`Server is running on port ${config.port}`); }); -process.on('uncaughtException', (err) => { - logger.fatal(err, 'Uncaught exception detected'); +process.on("uncaughtException", (err) => { + logger.fatal(err, "Uncaught exception detected"); server.close(() => { process.exit(1); }); setTimeout(() => { process.abort(); - }, 1000).unref() + }, 1000).unref(); process.exit(1); }); - -process.on('unhandledRejection', (reason, promise) => { - logger.error(reason, 'An unhandled promise rejection was detected'); +process.on("unhandledRejection", (reason, promise) => { + logger.error(reason, "An unhandled promise rejection was detected"); }); diff --git a/src/slackNotifier.js b/src/slackNotifier.js new file mode 100644 index 0000000..4ddabd4 --- /dev/null +++ b/src/slackNotifier.js @@ -0,0 +1,58 @@ +/* istanbul ignore file */ +import fetch from "node-fetch"; +import config from "../config/index.js"; +import logger from "./logger.js"; + +export default async function fetchAndSendLatestEntries(repo) { + if (!config.slackWebhookUrl) { + logger.info("No Slack webhook URL provided. Skipping sending to Slack."); + return; + } + + const latestEntries = await repo.findLatest(config.latestEntriesCount); + + const lastSentEntryTimestamp = await repo.getLastSentEntryTimestamp(); + + if ( + latestEntries.length === 0 || + new Date(latestEntries[0].updatedAt).getTime() <= lastSentEntryTimestamp + ) { + logger.info("No new changes to send to Slack."); + return; + } + + const message = latestEntries + .map( + (entry, index) => + `${index + 1}. *Name*: ${entry.name}\n` + + `> *Pubkey*: ${entry.pubkey}\n` + + `> *Relays*: ${entry.relays.join(", ")}\n` + + `> *Client IP*: ${entry.clientIp}\n` + + `> *User Agent*: ${entry.userAgent}\n` + + `> *Updated At*: ${entry.updatedAt}` + ) + .join("\n"); + + await sendSlackMessage(`Latest ${latestEntries.length} entries:\n${message}`); + logger.info("Sent latest entries to Slack."); + await repo.setLastSentEntryTimestamp( + new Date(latestEntries[0].updatedAt).getTime() + ); +} + +async function sendSlackMessage(message) { + try { + const response = await fetch(config.slackWebhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: message }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + logger.error(`Failed to send message to Slack: ${errorBody}`); + } + } catch (error) { + logger.error(`Error sending message to Slack: ${error.message}`); + } +}