From b5c19abb52fa3c33d8674c2c4d5849ddcb6e8f76 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Wed, 20 Aug 2025 12:23:53 -0700 Subject: [PATCH] feat: syndicate freeCodeCamp news posts --- package.json | 5 ++- pnpm-lock.yaml | 83 +++++++++++++++++++++++++++++++++++++++++ src/config/ids.ts | 1 + src/index.ts | 10 +++++ src/interfaces/amari.ts | 5 ++- src/interfaces/rss.ts | 40 ++++++++++++++++++++ src/modules/postNews.ts | 57 ++++++++++++++++++++++++++++ 7 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 src/interfaces/rss.ts create mode 100644 src/modules/postNews.ts diff --git a/package.json b/package.json index fe31209..458256a 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,15 @@ "@nhcarrigan/eslint-config": "5.2.0", "@nhcarrigan/typescript-config": "4.0.0", "@types/node": "24.3.0", + "@types/node-schedule": "2.1.8", "eslint": "9.33.0", "typescript": "5.9.2" }, "dependencies": { "@nhcarrigan/logger": "1.0.0", "discord.js": "14.21.0", - "fastify": "5.5.0" + "fastify": "5.5.0", + "node-schedule": "2.1.1", + "rss-parser": "3.13.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 716e8fd..8f10e25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: fastify: specifier: 5.5.0 version: 5.5.0 + node-schedule: + specifier: 2.1.1 + version: 2.1.1 + rss-parser: + specifier: 3.13.0 + version: 3.13.0 devDependencies: '@nhcarrigan/eslint-config': specifier: 5.2.0 @@ -27,6 +33,9 @@ importers: '@types/node': specifier: 24.3.0 version: 24.3.0 + '@types/node-schedule': + specifier: 2.1.8 + version: 2.1.8 eslint: specifier: 9.33.0 version: 9.33.0 @@ -505,6 +514,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/node-schedule@2.1.8': + resolution: {integrity: sha512-k00g6Yj/oUg/CDC+MeLHUzu0+OFxWbIqrFfDiLi6OPKxTujvpv29mHGM8GtKr7B+9Vv92FcK/8mRqi1DK5f3hA==} + '@types/node@24.3.0': resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==} @@ -855,6 +867,10 @@ packages: core-js-compat@3.45.0: resolution: {integrity: sha512-gRoVMBawZg0OnxaVv3zpqLLxaHmsubEGyTnqdpI/CEBvX4JadI1dMSHxagThprYRtSVbuQxvi6iUatdPxohHpA==} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -929,6 +945,9 @@ packages: electron-to-chromium@1.5.207: resolution: {integrity: sha512-mryFrrL/GXDTmAtIVMVf+eIXM09BBPlO5IQ7lUyKmK8d+A4VpRGG+M3ofoVef6qyF8s60rJei8ymlJxjUA8Faw==} + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} @@ -1501,6 +1520,9 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + long-timeout@0.1.1: + resolution: {integrity: sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -1508,6 +1530,10 @@ packages: loupe@3.2.0: resolution: {integrity: sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw==} + luxon@3.7.1: + resolution: {integrity: sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==} + engines: {node: '>=12'} + magic-bytes.js@1.12.1: resolution: {integrity: sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==} @@ -1554,6 +1580,10 @@ packages: node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + node-schedule@2.1.1: + resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==} + engines: {node: '>=6'} + normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} @@ -1793,6 +1823,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rss-parser@3.13.0: + resolution: {integrity: sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1815,6 +1848,9 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + secure-json-parse@4.0.0: resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==} @@ -1883,6 +1919,9 @@ packages: sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + sorted-array-functions@1.3.0: + resolution: {integrity: sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2191,6 +2230,14 @@ packages: utf-8-validate: optional: true + xml2js@0.5.0: + resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -2588,6 +2635,10 @@ snapshots: '@types/json5@0.0.29': {} + '@types/node-schedule@2.1.8': + dependencies: + '@types/node': 24.3.0 + '@types/node@24.3.0': dependencies: undici-types: 7.10.0 @@ -3025,6 +3076,10 @@ snapshots: dependencies: browserslist: 4.25.3 + cron-parser@4.9.0: + dependencies: + luxon: 3.7.1 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3112,6 +3167,8 @@ snapshots: electron-to-chromium@1.5.207: {} + entities@2.2.0: {} + error-ex@1.3.2: dependencies: is-arrayish: 0.2.1 @@ -3873,12 +3930,16 @@ snapshots: lodash@4.17.21: {} + long-timeout@0.1.1: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 loupe@3.2.0: {} + luxon@3.7.1: {} + magic-bytes.js@1.12.1: {} magic-string@0.30.17: @@ -3914,6 +3975,12 @@ snapshots: node-releases@2.0.19: {} + node-schedule@2.1.1: + dependencies: + cron-parser: 4.9.0 + long-timeout: 0.1.1 + sorted-array-functions: 1.3.0 + normalize-package-data@2.5.0: dependencies: hosted-git-info: 2.8.9 @@ -4183,6 +4250,11 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.46.3 fsevents: 2.3.3 + rss-parser@3.13.0: + dependencies: + entities: 2.2.0 + xml2js: 0.5.0 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -4212,6 +4284,8 @@ snapshots: safe-stable-stringify@2.5.0: {} + sax@1.4.1: {} + secure-json-parse@4.0.0: {} semver@5.7.2: {} @@ -4288,6 +4362,8 @@ snapshots: dependencies: atomic-sleep: 1.0.0 + sorted-array-functions@1.3.0: {} + source-map-js@1.2.1: {} spdx-correct@3.2.0: @@ -4630,4 +4706,11 @@ snapshots: ws@8.18.3: {} + xml2js@0.5.0: + dependencies: + sax: 1.4.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + yocto-queue@0.1.0: {} diff --git a/src/config/ids.ts b/src/config/ids.ts index 5c00ba1..02d3d30 100644 --- a/src/config/ids.ts +++ b/src/config/ids.ts @@ -8,6 +8,7 @@ export const ids = { channels: { mentorshipGoalForum: "1400629118110011526", mentorshipProjectForum: "1400616702265266186", + news: "1407804798677418198", }, roles: { nhcarrigan: "1355033209037127771", diff --git a/src/index.ts b/src/index.ts index 71876a7..1fa75b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,11 @@ */ import { Client, GatewayIntentBits, Events, Partials } from "discord.js"; +import { scheduleJob } from "node-schedule"; import { handleMessageCreate } from "./events/handleMessageCreate.js"; +import { + postFreeCodeCampNews, +} from "./modules/postNews.js"; import { respondToDm } from "./modules/respondToDm.js"; import { instantiateServer } from "./server/serve.js"; import { logger } from "./utils/logger.js"; @@ -20,11 +24,17 @@ const amari: Amari = { GatewayIntentBits.DirectMessages, ], partials: [ Partials.Channel ] }), + lastRssItems: { + freeCodeCamp: null, + }, }; amari.discord.once(Events.ClientReady, () => { void logger.log("debug", `Authenticated to Discord as ${amari.discord.user?.username ?? "unknown"}`); + scheduleJob("post news", "0 * * * *", async() => { + await postFreeCodeCampNews(amari); + }); }); amari.discord.on(Events.MessageCreate, (message) => { diff --git a/src/interfaces/amari.ts b/src/interfaces/amari.ts index b6f36ec..ca7ab89 100644 --- a/src/interfaces/amari.ts +++ b/src/interfaces/amari.ts @@ -7,5 +7,8 @@ import type { Client } from "discord.js"; export interface Amari { - discord: Client; + discord: Client; + lastRssItems: { + freeCodeCamp: string | null; + }; } diff --git a/src/interfaces/rss.ts b/src/interfaces/rss.ts new file mode 100644 index 0000000..4c1567d --- /dev/null +++ b/src/interfaces/rss.ts @@ -0,0 +1,40 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +/* eslint-disable @typescript-eslint/naming-convention -- Gonna have weird properties in this file. */ + +interface FreeCodeCampRSS { + items: Array<{ + "creator": string; + "title": string; + "link": string; + "pubDate": string; + "content:encoded": string; + "dc:creator": string; + "content": string; + "contentSnippet": string; + "guid": string; + "categories": Array; + "isoDate": Date; + }>; + feedUrl: string; + image: { + link: string; + url: string; + title: string; + }; + paginationLinks: { + self: string; + }; + title: string; + description: string; + generator: string; + link: string; + lastBuildDate: string; + ttl: string; +} + +export type { FreeCodeCampRSS }; diff --git a/src/modules/postNews.ts b/src/modules/postNews.ts new file mode 100644 index 0000000..2a3d92d --- /dev/null +++ b/src/modules/postNews.ts @@ -0,0 +1,57 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { ChannelType } from "discord.js"; +// eslint-disable-next-line @typescript-eslint/naming-convention -- Importing a class. +import Parser from "rss-parser"; +import { ids } from "../config/ids.js"; +import { logger } from "../utils/logger.js"; +import type { Amari } from "../interfaces/amari.js"; +import type { FreeCodeCampRSS } from "../interfaces/rss.js"; + +/** + * Fetches the RSS feed from freeCodeCamp News and posts the latest updates. + * @param amari - Amari's instance. + */ +const postFreeCodeCampNews = async(amari: Amari): Promise => { + try { + const parser = new Parser(); + const { items } + = await parser.parseURL("https://www.freecodecamp.org/news/rss"); + if (amari.lastRssItems.freeCodeCamp === null) { + amari.lastRssItems.freeCodeCamp = items[0]?.guid ?? null; + return; + } + const lastIndex = items.findIndex((item) => { + return item.guid === amari.lastRssItems.freeCodeCamp; + }); + const latestPosts + = lastIndex > -1 + ? items.slice(0, Math.min(lastIndex, 5)) + : items.slice(0, 5); + const channel + = amari.discord.channels.cache.get(ids.channels.news) + ?? await amari.discord.channels.fetch(ids.channels.news); + if (channel === null) { + throw new Error("Cannot find news channel."); + } + if (!channel.isSendable()) { + throw new Error("News channel is not sendable."); + } + await Promise.all(latestPosts.map(async(post) => { + const sent = await channel.send(post.link); + if (channel.type === ChannelType.GuildAnnouncement) { + await sent.crosspost(); + } + })); + } catch (error) { + if (error instanceof Error) { + await logger.error("post freecodecamp news module", error); + } + } +}; + +export { postFreeCodeCampNews };