feat: announce on Discourse support forum (#17)
Node.js CI / CI (push) Successful in 53s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m32s

## Summary

- Adds `announceOnDiscourse` module to post announcements to the NHCarrigan Discourse support forum (category 16), tagged by announcement type
- Adds `chunkContent` utility to split long announcements at paragraph/line boundaries for Discord (2000 chars), Reddit (40,000 chars), and Discourse (32,000 chars); Reddit overflows chain as nested replies, Discord as sequential messages, Discourse as sequential replies
- Refactors the announcement route to run all platforms concurrently via `Promise.allSettled`, ensuring a failure on any one platform never blocks the others, with all results reported back
- Fixes generation failure response from incorrect `201` to `500`

 This PR was created with love from Hikari~ 🌸

Reviewed-on: #17
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #17.
This commit is contained in:
2026-03-03 18:05:27 -08:00
committed by Naomi Carrigan
parent 46b285fd97
commit 637699f5bb
8 changed files with 1237 additions and 941 deletions
+84
View File
@@ -0,0 +1,84 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Multi-level split logic requires many lines. */
/* eslint-disable max-statements -- Multi-level split logic requires many statements. */
/* eslint-disable complexity -- Multi-level split logic has inherent branching complexity. */
/**
* Splits content into chunks that do not exceed the given character limit.
* Splits preferably at paragraph boundaries, then line boundaries,
* then hard-cuts at the limit as a last resort.
* @param content - The content to chunk.
* @param limit - The maximum character count per chunk.
* @returns An array of content chunks.
*/
export const chunkContent = (content: string, limit: number): Array<string> => {
if (content.length <= limit) {
return [ content ];
}
const chunks: Array<string> = [];
const paragraphs = content.split("\n\n");
let current = "";
for (const paragraph of paragraphs) {
const separator = current.length > 0
? "\n\n"
: "";
const combined = `${current}${separator}${paragraph}`;
if (combined.length <= limit) {
current = combined;
continue;
}
if (current.length > 0) {
chunks.push(current);
current = "";
}
if (paragraph.length <= limit) {
current = paragraph;
continue;
}
// Paragraph itself exceeds the limit — split by lines
const lines = paragraph.split("\n");
for (const line of lines) {
const lineSeparator = current.length > 0
? "\n"
: "";
const combinedLine = `${current}${lineSeparator}${line}`;
if (combinedLine.length <= limit) {
current = combinedLine;
continue;
}
if (current.length > 0) {
chunks.push(current);
current = "";
}
if (line.length <= limit) {
current = line;
continue;
}
// Single line exceeds limit — hard-cut
for (let index = 0; index < line.length; index = index + limit) {
chunks.push(line.slice(index, index + limit));
}
}
}
if (current.length > 0) {
chunks.push(current);
}
return chunks;
};