Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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")
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<query>
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
27 changes: 20 additions & 7 deletions src/cad/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/cad/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 10 additions & 3 deletions src/discord/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ const HTML_ENTITIES: Record<string, string> = { 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) => /<h[1-6]\b[^>]*\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;
Expand Down
12 changes: 6 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
}
Expand All @@ -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\`.`);
}
Expand Down Expand Up @@ -212,7 +212,7 @@ async function componentMessage(interaction: Interaction, env: Env): Promise<Mes
try {
page = await new CadClient(env.CAD_BASE_URL).getPage(slug);
} catch (error) {
console.error('CAD page for search result failed:', error);
console.error('CAD page for search result failed:', describeCadError(error));

return cadErrorMessage(error, brand, 'That page has been moved or removed. Try searching again.');
}
Expand Down Expand Up @@ -253,7 +253,7 @@ async function autocompleteResponse(interaction: Interaction, env: Env): Promise
choices = queryChoices(query, suggestions.map((suggestion) => suggestion.title));
}
} catch (error) {
console.error('CAD autocomplete failed:', error);
console.error('CAD autocomplete failed:', describeCadError(error));
choices = isQuery ? queryChoices(query, []) : [];
}

Expand Down
3 changes: 3 additions & 0 deletions template/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
55 changes: 55 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
@@ -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('<!doctype html>', 200);
assert.equal((await failure()).message, 'CAD_INVALID_RESPONSE');
});
4 changes: 4 additions & 0 deletions test/sections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
3 changes: 3 additions & 0 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading