From 8851d97ec30e6c672c30f398d9ba1e32c045b887 Mon Sep 17 00:00:00 2001 From: Amanda Martin <290842976+amanda-vapi@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:01:10 -0700 Subject: [PATCH] test: add Discord product news preview workflow --- .../workflows/test-discord-product-news.yml | 83 ++++++ scripts/discord/README.md | 38 +++ scripts/discord/post-whats-new.mjs | 76 ++++++ scripts/discord/product-news.mjs | 251 ++++++++++++++++++ scripts/discord/product-news.test.mjs | 64 +++++ 5 files changed, 512 insertions(+) create mode 100644 .github/workflows/test-discord-product-news.yml create mode 100644 scripts/discord/README.md create mode 100644 scripts/discord/post-whats-new.mjs create mode 100644 scripts/discord/product-news.mjs create mode 100644 scripts/discord/product-news.test.mjs diff --git a/.github/workflows/test-discord-product-news.yml b/.github/workflows/test-discord-product-news.yml new file mode 100644 index 000000000..302e1794a --- /dev/null +++ b/.github/workflows/test-discord-product-news.yml @@ -0,0 +1,83 @@ +name: Test - Discord Product News + +on: + pull_request: + paths: + - .github/workflows/test-discord-product-news.yml + - scripts/discord/** + - fern/changelog/** + workflow_dispatch: + inputs: + mode: + description: Preview the payload or send it to the test Discord server + required: true + type: choice + default: preview + options: + - preview + - send-test + changelog_file: + description: Optional fern/changelog/YYYY-MM-DD.mdx path; blank uses latest + required: false + type: string + +permissions: + contents: read + +jobs: + preview: + if: github.event_name == 'pull_request' || inputs.mode == 'preview' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Run tests + run: node --test scripts/discord/product-news.test.mjs + + - name: Preview Discord payload + env: + CHANGELOG_FILE: ${{ inputs.changelog_file }} + run: | + args=(--prefix "[TEST]" --dry-run) + if [[ -n "$CHANGELOG_FILE" ]]; then + args+=(--file "$CHANGELOG_FILE") + fi + node scripts/discord/post-whats-new.mjs "${args[@]}" + + send-test: + if: github.event_name == 'workflow_dispatch' && inputs.mode == 'send-test' + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: discord-product-news-test + concurrency: + group: discord-product-news-test + cancel-in-progress: false + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Run tests + run: node --test scripts/discord/product-news.test.mjs + + - name: Post to test Discord server + env: + CHANGELOG_FILE: ${{ inputs.changelog_file }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: | + args=(--prefix "[TEST]") + if [[ -n "$CHANGELOG_FILE" ]]; then + args+=(--file "$CHANGELOG_FILE") + fi + node scripts/discord/post-whats-new.mjs "${args[@]}" diff --git a/scripts/discord/README.md b/scripts/discord/README.md new file mode 100644 index 000000000..7775df971 --- /dev/null +++ b/scripts/discord/README.md @@ -0,0 +1,38 @@ +# Discord product-news test + +This integration posts a dated `fern/changelog/YYYY-MM-DD.mdx` file to a +Discord channel through an incoming webhook. It is intentionally test-only: +there is no schedule, merge trigger, or production Discord destination. + +## Local preview + +```bash +node scripts/discord/post-whats-new.mjs \ + --file fern/changelog/2026-09-14.mdx \ + --prefix "[TEST]" \ + --dry-run +``` + +If `--file` is omitted, the latest dated changelog file is selected. + +Run the tests with: + +```bash +node --test scripts/discord/product-news.test.mjs +``` + +## GitHub test environment + +1. Create a Discord incoming webhook in the personal test server. +2. In the GitHub repository, create an environment named + `discord-product-news-test`. +3. Add the webhook URL to that environment as a secret named + `DISCORD_WEBHOOK_URL`. +4. Merge the test workflow into the default branch. GitHub only enables + manual `workflow_dispatch` runs for workflow files on the default branch. +5. Open **Actions → Test - Discord Product News**. +6. Run `preview` first, inspect the logs, then run `send-test`. + +The script asks Discord to confirm each saved message, retries temporary API +and rate-limit failures, disables all mentions, and never prints the webhook +URL. Production scheduling and correction behavior are deliberately deferred. diff --git a/scripts/discord/post-whats-new.mjs b/scripts/discord/post-whats-new.mjs new file mode 100644 index 000000000..3400865fa --- /dev/null +++ b/scripts/discord/post-whats-new.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +import { buildProductNews, postProductNews } from "./product-news.mjs"; + +function parseArguments(arguments_) { + const options = { + dryRun: false, + prefix: "", + requestedPath: "", + username: "Product News Bot", + }; + + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + + if (argument === "--dry-run") { + options.dryRun = true; + continue; + } + + if (["--file", "--prefix", "--username"].includes(argument)) { + const value = arguments_[index + 1]; + if (!value) { + throw new Error(`${argument} requires a value`); + } + index += 1; + + if (argument === "--file") options.requestedPath = value; + if (argument === "--prefix") options.prefix = value; + if (argument === "--username") options.username = value; + continue; + } + + throw new Error(`Unknown argument: ${argument}`); + } + + return options; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + const productNews = await buildProductNews({ + prefix: options.prefix, + requestedPath: options.requestedPath, + }); + + console.log(`Source: ${productNews.changelogPath}`); + console.log(`Docs: ${productNews.canonicalUrl}`); + console.log(`Discord messages: ${productNews.messages.length}`); + + if (options.dryRun) { + productNews.messages.forEach((message, index) => { + console.log(`\n--- Message ${index + 1}/${productNews.messages.length} ---\n`); + console.log(message); + }); + return; + } + + const webhookUrl = process.env.DISCORD_WEBHOOK_URL; + if (!webhookUrl) { + throw new Error("DISCORD_WEBHOOK_URL is required unless --dry-run is used"); + } + + const postedMessages = await postProductNews({ + webhookUrl, + messages: productNews.messages, + username: options.username, + }); + + console.log(`Posted ${postedMessages.length} Discord message(s) successfully.`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/scripts/discord/product-news.mjs b/scripts/discord/product-news.mjs new file mode 100644 index 000000000..cf73e03c2 --- /dev/null +++ b/scripts/discord/product-news.mjs @@ -0,0 +1,251 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +export const DISCORD_MESSAGE_LIMIT = 2_000; +export const DOCS_BASE_URL = "https://docs.vapi.ai"; + +const CHANGELOG_PATH_PATTERN = + /^fern\/changelog\/(\d{4})-(\d{2})-(\d{2})\.mdx$/; + +export async function resolveChangelogFile(requestedPath, cwd = process.cwd()) { + if (requestedPath) { + const normalizedPath = requestedPath.replaceAll("\\", "/"); + + if (!CHANGELOG_PATH_PATTERN.test(normalizedPath)) { + throw new Error( + "Changelog path must match fern/changelog/YYYY-MM-DD.mdx", + ); + } + + return normalizedPath; + } + + const changelogDirectory = path.join(cwd, "fern", "changelog"); + const filenames = (await readdir(changelogDirectory)) + .filter((filename) => /^\d{4}-\d{2}-\d{2}\.mdx$/.test(filename)) + .sort(); + + const latestFilename = filenames.at(-1); + if (!latestFilename) { + throw new Error("No dated changelog files were found"); + } + + return `fern/changelog/${latestFilename}`; +} + +export function buildCanonicalUrl(changelogPath) { + const match = CHANGELOG_PATH_PATTERN.exec(changelogPath); + if (!match) { + throw new Error( + "Changelog path must match fern/changelog/YYYY-MM-DD.mdx", + ); + } + + const [, year, month, day] = match; + return `${DOCS_BASE_URL}/whats-new/${year}/${Number(month)}/${Number(day)}`; +} + +export function normalizeChangelogMarkdown(markdown) { + let normalized = markdown.replace(/^\uFEFF/, "").trim(); + + if (normalized.startsWith("---\n")) { + const frontmatterEnd = normalized.indexOf("\n---\n", 4); + if (frontmatterEnd === -1) { + throw new Error("Changelog contains unterminated frontmatter"); + } + normalized = normalized.slice(frontmatterEnd + 5).trim(); + } + + if (!/^#\s+\S/m.test(normalized)) { + throw new Error("Changelog must contain a level-one title"); + } + + if (/<\/?[A-Z][A-Za-z0-9.]*(?:\s[^>]*)?>/.test(normalized)) { + throw new Error( + "Changelog contains an MDX component that needs an explicit Discord conversion", + ); + } + + return normalized.replace( + /(!?\[[^\]]*\]\()\/(?!\/)/g, + `$1${DOCS_BASE_URL}/`, + ); +} + +function splitOversizedBlock(block, limit) { + const chunks = []; + let remainder = block; + + while (remainder.length > limit) { + let splitAt = remainder.lastIndexOf("\n", limit); + if (splitAt < Math.floor(limit / 2)) { + splitAt = remainder.lastIndexOf(" ", limit); + } + if (splitAt < Math.floor(limit / 2)) { + splitAt = limit; + } + + chunks.push(remainder.slice(0, splitAt).trimEnd()); + remainder = remainder.slice(splitAt).trimStart(); + } + + if (remainder) { + chunks.push(remainder); + } + + return chunks; +} + +export function splitDiscordMessages(text, limit = DISCORD_MESSAGE_LIMIT) { + if (!Number.isInteger(limit) || limit < 1) { + throw new Error("Discord message limit must be a positive integer"); + } + + const blocks = text + .trim() + .split(/\n{2,}/) + .map((block) => block.trim()) + .filter(Boolean) + .flatMap((block) => splitOversizedBlock(block, limit)); + + const messages = []; + let currentMessage = ""; + + for (const block of blocks) { + const candidate = currentMessage + ? `${currentMessage}\n\n${block}` + : block; + + if (candidate.length <= limit) { + currentMessage = candidate; + continue; + } + + if (currentMessage) { + messages.push(currentMessage); + } + currentMessage = block; + } + + if (currentMessage) { + messages.push(currentMessage); + } + + return messages; +} + +export async function buildProductNews({ + cwd = process.cwd(), + requestedPath, + prefix = "", +}) { + const changelogPath = await resolveChangelogFile(requestedPath, cwd); + const absolutePath = path.join(cwd, changelogPath); + const rawMarkdown = await readFile(absolutePath, "utf8"); + const markdown = normalizeChangelogMarkdown(rawMarkdown); + const canonicalUrl = buildCanonicalUrl(changelogPath); + const prefixBlock = prefix ? `**${prefix}**\n\n` : ""; + const completeMessage = `${prefixBlock}${markdown}\n\n**Read the full update:** ${canonicalUrl}`; + + return { + canonicalUrl, + changelogPath, + messages: splitDiscordMessages(completeMessage), + }; +} + +export function validateDiscordWebhookUrl(webhookUrl) { + let parsedUrl; + try { + parsedUrl = new URL(webhookUrl); + } catch { + throw new Error("Discord webhook URL is invalid"); + } + + const isDiscordHost = + parsedUrl.hostname === "discord.com" || + parsedUrl.hostname.endsWith(".discord.com") || + parsedUrl.hostname === "discordapp.com" || + parsedUrl.hostname.endsWith(".discordapp.com"); + + if ( + parsedUrl.protocol !== "https:" || + !isDiscordHost || + !parsedUrl.pathname.startsWith("/api/webhooks/") + ) { + throw new Error("Webhook must be an HTTPS Discord webhook URL"); + } + + parsedUrl.searchParams.set("wait", "true"); + return parsedUrl; +} + +function wait(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function sendWebhookMessage({ + webhookUrl, + content, + username, + maxAttempts = 4, +}) { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const response = await fetch(webhookUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + allowed_mentions: { parse: [] }, + content, + username, + }), + }); + + if (response.ok) { + return response.json(); + } + + const responseBody = await response.text(); + const shouldRetry = response.status === 429 || response.status >= 500; + if (!shouldRetry || attempt === maxAttempts) { + throw new Error( + `Discord webhook failed with HTTP ${response.status}: ${responseBody.slice(0, 500)}`, + ); + } + + let retryAfterMilliseconds = 1_000 * attempt; + if (response.status === 429) { + try { + const rateLimit = JSON.parse(responseBody); + retryAfterMilliseconds = Math.ceil(Number(rateLimit.retry_after) * 1_000); + } catch { + // Fall back to the incremental delay above. + } + } + + await wait(Math.min(Math.max(retryAfterMilliseconds, 250), 30_000)); + } + + throw new Error("Discord webhook failed unexpectedly"); +} + +export async function postProductNews({ + webhookUrl, + messages, + username = "Product News Bot", +}) { + const validatedWebhookUrl = validateDiscordWebhookUrl(webhookUrl); + const postedMessages = []; + + for (const content of messages) { + postedMessages.push( + await sendWebhookMessage({ + webhookUrl: validatedWebhookUrl, + content, + username, + }), + ); + } + + return postedMessages; +} diff --git a/scripts/discord/product-news.test.mjs b/scripts/discord/product-news.test.mjs new file mode 100644 index 000000000..2fdf9cc5b --- /dev/null +++ b/scripts/discord/product-news.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildCanonicalUrl, + DISCORD_MESSAGE_LIMIT, + normalizeChangelogMarkdown, + splitDiscordMessages, + validateDiscordWebhookUrl, +} from "./product-news.mjs"; + +test("builds the public URL from a dated changelog path", () => { + assert.equal( + buildCanonicalUrl("fern/changelog/2026-09-07.mdx"), + "https://docs.vapi.ai/whats-new/2026/9/7", + ); +}); + +test("converts root-relative docs links for Discord", () => { + const markdown = normalizeChangelogMarkdown(` +# What's New + +Read the [simulations guide](/observability/simulations-overview). + `); + + assert.match( + markdown, + /\[simulations guide\]\(https:\/\/docs\.vapi\.ai\/observability\/simulations-overview\)/, + ); +}); + +test("rejects MDX components instead of posting broken markup", () => { + assert.throws( + () => normalizeChangelogMarkdown("# What's New\n\nNews"), + /MDX component/, + ); +}); + +test("splits long updates within Discord's content limit", () => { + const paragraphs = Array.from( + { length: 20 }, + (_, index) => `${index + 1}. ${"Product update ".repeat(20)}`, + ); + const messages = splitDiscordMessages(paragraphs.join("\n\n")); + + assert.ok(messages.length > 1); + assert.ok( + messages.every((message) => message.length <= DISCORD_MESSAGE_LIMIT), + ); + assert.match(messages.at(-1), /20\./); +}); + +test("accepts only Discord HTTPS webhook URLs", () => { + assert.equal( + validateDiscordWebhookUrl( + "https://discord.com/api/webhooks/123/example-token", + ).searchParams.get("wait"), + "true", + ); + assert.throws( + () => validateDiscordWebhookUrl("https://example.com/api/webhooks/123/token"), + /Discord webhook/, + ); +});