From d52a54e700cdbd68c6484e80b8d9e23cf205efd4 Mon Sep 17 00:00:00 2001 From: Simon Grieve Date: Tue, 29 Sep 2026 09:28:19 +0100 Subject: [PATCH] Release ctrlaltbot 1.1.1 - Read Markdown from the page API's content field when a site sends it there, fixing section text against docs sites that predate the markdown field - Enable global_fetch_strictly_public in the template so the bot can reach a docs site hosted as a Worker in the same Cloudflare account (error 1042) - Log CAD failures with the error code, URL, and status; report the same-account block as CAD_BLOCKED - Publish to npm with trusted publishing instead of a token --- .github/workflows/release.yml | 8 +++-- CHANGELOG.md | 8 +++++ README.md | 4 ++- package-lock.json | 4 +-- package.json | 2 +- src/cad/client.ts | 27 ++++++++++++----- src/cad/types.ts | 2 +- src/discord/sections.ts | 13 +++++++-- src/index.ts | 12 ++++---- template/wrangler.jsonc | 3 ++ test/client.test.ts | 55 +++++++++++++++++++++++++++++++++++ test/sections.test.ts | 4 +++ wrangler.jsonc | 3 ++ 13 files changed, 122 insertions(+), 23 deletions(-) create mode 100644 test/client.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75633ce..6a2903d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,8 @@ on: permissions: contents: write + # Lets npm verify this workflow through trusted publishing, so no npm token is needed. + id-token: write jobs: release: @@ -23,6 +25,10 @@ jobs: cache: npm registry-url: https://registry.npmjs.org + # Trusted publishing needs npm 11.5.1 or later; Node 22 ships with npm 10. + - name: Update npm + run: npm install -g npm@latest + - name: Install dependencies run: npm ci @@ -48,8 +54,6 @@ jobs: run: npm run release - name: Publish package to npm - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | PACKAGE_NAME=$(node -p "require('./package.json').name") PACKAGE_VERSION=$(node -p "require('./package.json').version") diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c7e9bf..f26b328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project are documented here. +## [1.1.1] - 2026-09-29 + +### Fixed + +- Section text works again with docs sites that send Markdown in the page API's `content` field rather than as `markdown`. In 1.1.0 these sites showed "Open the page to read this section." +- The template enables the `global_fetch_strictly_public` compatibility flag, so the bot can reach a docs site hosted as a Worker in the same Cloudflare account. Without it, Cloudflare blocks the request with error 1042 and every search fails. Existing deployments need to add the flag to their `wrangler.jsonc`. +- CAD failures are logged with the error code, request URL, and HTTP status instead of only a stack trace, and Cloudflare's same-account block is reported as `CAD_BLOCKED` with instructions rather than as a missing page. + ## [1.1.0] - 2026-09-29 ### Changed diff --git a/README.md b/README.md index 56ef2cc..a0a15a3 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,9 @@ The bot checks its command definitions when a Worker instance handles its first ## CAD requirements -The configured CAD site must be running and reachable by the Worker. The bot expects these endpoints: +The configured CAD site must be running and reachable by the Worker. If the docs site is also a Cloudflare Worker in the same account, the bot needs the `global_fetch_strictly_public` compatibility flag, which the template includes; without it Cloudflare blocks the request with error 1042, which the bot logs as `CAD_BLOCKED`. + +The bot expects these endpoints: ```text GET /api/search?q= diff --git a/package-lock.json b/package-lock.json index 6d9c4c9..0b2f82b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ctrlaltbot", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ctrlaltbot", - "version": "1.1.0", + "version": "1.1.1", "license": "ISC", "dependencies": { "discord-interactions": "^4.4.0" diff --git a/package.json b/package.json index 669946e..939cb6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ctrlaltbot", - "version": "1.1.0", + "version": "1.1.1", "description": "Discord bot for ctrl alt doc documentation sites, running as a Cloudflare Worker.", "type": "module", "main": "dist/index.js", diff --git a/src/cad/client.ts b/src/cad/client.ts index 3fa8bb3..4629414 100644 --- a/src/cad/client.ts +++ b/src/cad/client.ts @@ -7,6 +7,13 @@ import type { const CAD_REQUEST_TIMEOUT_MS = 10_000; +/** A readable summary of a CAD request failure for logs, including the request URL. */ +export function describeCadError(error: unknown): string { + if (!(error instanceof Error)) return String(error); + + return error.cause ? `${error.message} (${String(error.cause)})` : error.message; +} + export class CadClient { constructor( private readonly baseUrl: string, @@ -42,21 +49,27 @@ export class CadClient { response = await fetch(url, { signal: AbortSignal.timeout(this.timeoutMs) }); - } catch { - throw new Error('CAD_UNREACHABLE'); - } - - if (response.status === 404) { - throw new Error('CAD_NOT_FOUND'); + } catch (error) { + throw new Error('CAD_UNREACHABLE', { cause: `${url}: ${error instanceof Error ? error.message : String(error)}` }); } if (!response.ok) { - throw new Error(`CAD_HTTP_${response.status}`); + const body = await response.text().catch(() => ''); + + // Cloudflare answers with a 404 when a Worker fetches another Worker in the same account without the flag. + if (body.includes('error code: 1042')) { + throw new Error('CAD_BLOCKED', { + cause: `${url}: Cloudflare error 1042. The docs site is a Worker in the same account; add the "global_fetch_strictly_public" compatibility flag to the bot's wrangler.jsonc.` + }); + } + + throw new Error(response.status === 404 ? 'CAD_NOT_FOUND' : `CAD_HTTP_${response.status}`, { cause: `${url}: HTTP ${response.status}` }); } try { return await response.json() as T; } catch { + throw new Error('CAD_INVALID_RESPONSE', { cause: `${url}: response was not JSON` }); throw new Error('CAD_INVALID_RESPONSE'); } } diff --git a/src/cad/types.ts b/src/cad/types.ts index d91b3ff..81f9fe0 100644 --- a/src/cad/types.ts +++ b/src/cad/types.ts @@ -23,7 +23,7 @@ export interface PageResult { breadcrumb?: string[]; sinceVersion?: string; toc?: TocItem[]; - /** Rendered HTML. */ + /** Rendered HTML, or Markdown on some older sites. */ content?: string; /** Markdown source, preferred for section text when CAD provides it. */ markdown?: string; diff --git a/src/discord/sections.ts b/src/discord/sections.ts index 335a7e5..ceae876 100644 --- a/src/discord/sections.ts +++ b/src/discord/sections.ts @@ -7,10 +7,17 @@ const HTML_ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot // Matches CAD's table-of-contents titles, which drop inline Markdown. const plain = (value: string) => value.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`~]/g, '').trim().toLowerCase(); -/** The text of one section of a page, formatted for a Discord embed. */ +// Rendered pages always contain heading tags with the anchor IDs the table of contents refers to. +const isHtml = (content: string) => /]*\bid=/i.test(content); + +/** + * The text of one section of a page, formatted for a Discord embed. CAD sites + * send Markdown as `markdown`; some send it as `content`, where others send HTML. + */ export function sectionText(page: PageResult, selected: TocItem): string | undefined { - const text = page.markdown !== undefined - ? fromMarkdown(page.markdown, page.toc ?? [], selected) + const markdown = page.markdown ?? (page.content && !isHtml(page.content) ? page.content : undefined); + const text = markdown !== undefined + ? fromMarkdown(markdown, page.toc ?? [], selected) : page.content ? fromHtml(page.content, selected) : undefined; return text ? truncateMarkdown(text, MAX_SECTION_LENGTH) : undefined; diff --git a/src/index.ts b/src/index.ts index 86470d9..7f07c24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { verifyKey } from 'discord-interactions'; -import { CadClient } from './cad/client.js'; +import { CadClient, describeCadError } from './cad/client.js'; import type { PageResult } from './cad/types.js'; import { formatPageResult, @@ -114,14 +114,14 @@ async function searchMessage(query: string, env: Env, brand: BrandConfig): Promi // A matched heading shows that section's text, which needs the full page. const topPage = top?.heading ? await cadClient.getPage(top.slug).catch((error: unknown) => { - console.error('CAD page for search result failed:', error); + console.error('CAD page for search result failed:', describeCadError(error)); return undefined; }) : undefined; return formatSearchResults(results, env.CAD_BASE_URL, query, brand, topPage); } catch (error) { - console.error('CAD search failed:', error); + console.error('CAD search failed:', describeCadError(error)); return cadErrorMessage(error, brand); } @@ -133,7 +133,7 @@ async function pageMessage(slug: string, header: string | undefined, env: Env, b return formatPageResult(page, env.CAD_BASE_URL, brand, header); } catch (error) { - console.error('CAD page failed:', error); + console.error('CAD page failed:', describeCadError(error)); return cadErrorMessage(error, brand, `There’s no page at \`${slug}\`. Pick one of the suggestions while typing, or try \`/ask\`.`); } @@ -212,7 +212,7 @@ async function componentMessage(interaction: Interaction, env: Env): Promise suggestion.title)); } } catch (error) { - console.error('CAD autocomplete failed:', error); + console.error('CAD autocomplete failed:', describeCadError(error)); choices = isQuery ? queryChoices(query, []) : []; } diff --git a/template/wrangler.jsonc b/template/wrangler.jsonc index 1b6563b..f22de27 100644 --- a/template/wrangler.jsonc +++ b/template/wrangler.jsonc @@ -2,6 +2,9 @@ "name": "ctrlaltbot", "main": "src/index.js", "compatibility_date": "2026-09-13", + // Lets the bot reach a docs site hosted as a Worker in the same Cloudflare + // account, which Cloudflare otherwise blocks with a 404 (error 1042). + "compatibility_flags": ["global_fetch_strictly_public"], "vars": { "CAD_BASE_URL": "https://your-docs.example.com", "DISCORD_CLIENT_ID": "YOUR_DISCORD_APPLICATION_ID" diff --git a/test/client.test.ts b/test/client.test.ts new file mode 100644 index 0000000..c6b57f5 --- /dev/null +++ b/test/client.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; + +import { CadClient, describeCadError } from '../src/cad/client.js'; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const respondWith = (body: string, status: number) => { + globalThis.fetch = (async () => new Response(body, { status })) as typeof fetch; +}; + +const failure = async () => { + try { + await new CadClient('https://docs.example.com').search('eggs'); + } catch (error) { + return error as Error; + } + assert.fail('expected the request to fail'); +}; + +test('explains Cloudflare blocking a same-account Worker', async () => { + respondWith('error code: 1042', 404); + const error = await failure(); + + assert.equal(error.message, 'CAD_BLOCKED'); + assert.match(describeCadError(error), /global_fetch_strictly_public/); + assert.match(describeCadError(error), /https:\/\/docs\.example\.com\/api\/search\?q=eggs/); +}); + +test('reports missing pages and HTTP errors with the URL', async () => { + respondWith('Not found', 404); + assert.equal((await failure()).message, 'CAD_NOT_FOUND'); + + respondWith('Oops', 502); + const error = await failure(); + assert.equal(error.message, 'CAD_HTTP_502'); + assert.match(describeCadError(error), /api\/search\?q=eggs: HTTP 502/); +}); + +test('keeps the network error when CAD is unreachable', async () => { + globalThis.fetch = (async () => { throw new TypeError('fetch failed'); }) as typeof fetch; + const error = await failure(); + + assert.equal(error.message, 'CAD_UNREACHABLE'); + assert.match(describeCadError(error), /fetch failed/); +}); + +test('flags responses that are not JSON', async () => { + respondWith('', 200); + assert.equal((await failure()).message, 'CAD_INVALID_RESPONSE'); +}); diff --git a/test/sections.test.ts b/test/sections.test.ts index 0bb02f5..fb5dcc3 100644 --- a/test/sections.test.ts +++ b/test/sections.test.ts @@ -88,6 +88,10 @@ test('falls back to rendered HTML when CAD sends no Markdown', () => { assert.ok(!text.includes('Copy') && !text.includes('ts\n')); }); +test('reads Markdown sent as content by sites that predate the markdown field', () => { + assert.equal(sectionText(page({ content: markdown }), toc[3]!), 'First notes.'); +}); + test('returns nothing for a heading that is not on the page', () => { assert.equal(sectionText(page({ markdown }), { id: 'missing', title: 'Missing', level: 2 }), undefined); }); diff --git a/wrangler.jsonc b/wrangler.jsonc index 3452b2a..ec10e90 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -4,6 +4,9 @@ "name": "ctrlaltbot-dev", "main": "src/index.ts", "compatibility_date": "2026-09-13", + // Lets the bot reach a docs site hosted as a Worker in the same Cloudflare + // account, which Cloudflare otherwise blocks with a 404 (error 1042). + "compatibility_flags": ["global_fetch_strictly_public"], "vars": { "CAD_BASE_URL": "https://your-docs.example.com", "DISCORD_CLIENT_ID": "YOUR_DISCORD_APPLICATION_ID"