diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2bbcf0d..75633ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,9 +51,10 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | + PACKAGE_NAME=$(node -p "require('./package.json').name") PACKAGE_VERSION=$(node -p "require('./package.json').version") - if npm view "cad-discord@$PACKAGE_VERSION" version >/dev/null 2>&1; then - echo "cad-discord@$PACKAGE_VERSION is already published; skipping npm publish." + if npm view "$PACKAGE_NAME@$PACKAGE_VERSION" version >/dev/null 2>&1; then + echo "$PACKAGE_NAME@$PACKAGE_VERSION is already published; skipping npm publish." else npm publish --access public fi diff --git a/.gitignore b/.gitignore index 7d56a1d..d4214a2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ release/ .dev.vars .wrangler/ +to-do.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b7cc3db..7c7e9bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ All notable changes to this project are documented here. +## [1.1.0] - 2026-09-29 + +### Changed + +- Renamed from `cad-discord` to `ctrlaltbot`. The package is now a library that operators install through the template repository; the manual register command is `ctrlaltbot-register`. +- Settings storage is created automatically on deploy; `wrangler.jsonc` no longer needs a KV namespace ID. +- Server colour settings are only read from storage for commands that display an embed. + +### Added + +- Deploy to Cloudflare button and an operator template (`template/`) that receives updates through Dependabot, with automatic merging of minor and patch versions. +- The Worker registers its own slash commands when their definitions change, checked on the first interaction after a deploy and hourly, so updates need no manual registration. Requires the `DISCORD_TOKEN` secret. +- `/ask ` command for natural-language searches. +- Autocomplete on search queries, keeping the typed text as the first choice. +- Search results link to the best-matching heading when CAD provides one, and show that section's text. +- A menu of up to ten matching pages replaces the "Also matching" links; choosing one switches the result in place. +- Automated tests for search result formatting and autocomplete choices. + +### Fixed + +- Slow documentation servers no longer make commands fail with "The application did not respond": replies are deferred after 2.5 seconds and completed when CAD responds. +- `/docs page … header:` now shows the selected section's text instead of a placeholder, stops at the next heading, keeps code blocks and formatting, and shows up to 1,500 characters instead of 200. +- Missing pages report "Page not found" instead of a generic server error. +- Unknown commands and missing options get an explanatory ephemeral reply instead of failing the interaction. +- Autocomplete responses respect Discord's 25-choice and 100-character limits. + +### Removed + +- `discord.js` and `dotenv` dependencies. +- The `cad-discord` binary, which started the Worker as a Node process. + +## [1.0.7] - 2026-09-26 + +### Fixed + +- Point the packaged Wrangler deployment at the included compiled `dist/index.js` entrypoint. +- Document the build step required when deploying directly from source. + ## [1.0.6] - 2026-09-26 ### Fixed diff --git a/README.md b/README.md index 191fa87..56ef2cc 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ -# ctrl alt doc — Discord bot +# ctrlaltbot -This is the Discord companion for [ctrl alt doc](https://github.com//ctrl-alt-doc). It provides a small Discord interface to a configured CAD documentation site. +The Discord companion for [ctrl alt doc](https://github.com/ctrl-alt-doc/ctrlaltdoc). It lets people search and read a ctrl alt doc documentation site without leaving Discord. -[Download the latest release](https://github.com/ctrl-alt-doc/cad-bot/releases/latest) - -[Install `cad-discord` from npm](https://www.npmjs.com/package/cad-discord) +[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/ctrl-alt-doc/ctrlaltbot/tree/main/template) The bot is deliberately a thin integration layer: @@ -17,11 +15,12 @@ CAD remains responsible for finding documents, resolving slugs, and maintaining ## Commands ```text +/ask /docs search /docs page [header] ``` -`/docs search` uses CAD’s title-priority search. A single result is returned directly; multiple results include a heading and compact title/link entries. Discord autocomplete suggests page titles while typing. +`/ask` and `/docs search` run the same search. Queries can be written as questions, such as `/ask how do I add a callout`: CAD matches individual words, ignores filler words, and tolerates small typos. When a heading matches better than the page title, the result shows that section's text and links straight to it. When there are several results, a menu below the embed lists up to ten; choosing one switches the embed to that page or section. Only the person who searched can switch the shared message. Anyone else who uses the menu gets their own private copy. Autocomplete suggests matching page titles while typing, with your own wording kept as the first choice. `/docs page` retrieves page metadata from CAD. The optional `header` argument is autocomplete-enabled from the page table of contents. When selected, the embed shows that section's content and its link opens the page at the matching heading. Pages may define an optional YAML `excerpt`; pages without one return no excerpt. @@ -33,228 +32,134 @@ Server administrators can set the embed colour with: The setting is stored per Discord server in Cloudflare KV. The default colour is `#ce0985`. -Responses use a consistent branded embed with an `Open page` link button. Empty and error states use the same accent colour, and error replies are ephemeral. Set `BRAND_AVATAR_URL` to a stable public avatar URL for the embed footer; otherwise the bot uses `/brand/ctrl-alt-bot-512.png` on the CAD site. +Responses use a consistent branded embed with an `Open page` link button. Empty and error states use the same accent colour, and error replies are ephemeral. Set `BRAND_AVATAR_URL` to a stable public avatar URL for the embed footer. ## Installation -This bot runs as a Cloudflare Worker. The simplest supported setup is: create a Discord application, deploy the Worker, connect Discord to its URL, and register the commands. +The bot runs as a Cloudflare Worker in your own Cloudflare account. You need a Cloudflare account, a GitHub or GitLab account, and a ctrl alt doc site the Worker can reach. ### 1. Create the Discord application In the [Discord Developer Portal](https://discord.com/developers/applications): 1. Create an application and add a bot. -2. Copy the **Application ID**, **Bot Token**, and **Public Key**. +2. Note the **Application ID** and **Public Key** from **General Information**, and the bot **Token** from **Bot** (select **Reset Token**). Keep the token secret. 3. Invite the bot to your server using the `bot` and `applications.commands` scopes. -Keep the token secret. +### 2. Deploy -### 2. Download the bot +Select **Deploy to Cloudflare** above. Cloudflare will: -Use either the source repository or a GitHub release archive. +- create a repository for your bot in your GitHub or GitLab account; +- ask for your docs site address, the Application ID, the Public Key, and the bot token; +- create the storage the bot uses for server settings; +- build and deploy the bot, and redeploy it whenever that repository changes. -From source: +Copy the Worker URL shown when the deployment finishes. -```bash -git clone https://github.com/ctrl-alt-doc/cad-bot.git -cd cad-bot -npm ci -``` +### 3. Connect Discord to the Worker -From a release archive: +In the Discord Developer Portal, open **General Information** and set **Interactions Endpoint URL** to the Worker URL. Discord verifies the endpoint when you save. -```bash -tar -xzf cad-discord-.tar.gz -cd cad-discord- -npm ci --omit=dev -``` +The bot registers its slash commands itself as soon as Discord first contacts it, so `/ask` and `/docs` appear within a minute or two. There is nothing to run. -### 3. Log in to Cloudflare +## Updates -```bash -npx wrangler login -``` +Your bot's repository contains only your configuration. The bot itself is the [`ctrlaltbot`](https://www.npmjs.com/package/ctrlaltbot) npm package, and the repository is set up so that new versions reach you without any commands: -### 4. Create the colour-settings database +1. When a new version is published, Dependabot opens a pull request in your repository. +2. Compatible updates (minor and patch versions) are merged automatically by the included workflow. Major versions, which may need changes on your side, wait for you to review and merge them. +3. Merging redeploys the bot, and the bot updates its slash commands on its own. -Run this once: +To review every update yourself, delete `.github/workflows/auto-update.yml` from your repository. To check for updates immediately, open your repository's **Insights → Dependency graph → Dependabot** and select **Check for updates**. -```bash -npx wrangler kv namespace create BRAND_SETTINGS -``` +## Manual installation -Copy the returned ID into `wrangler.jsonc`, replacing: +To deploy without the button, for example from CI you already run: -```text -REPLACE_WITH_BRAND_SETTINGS_NAMESPACE_ID -``` - -Before deploying, edit the same file and replace these example values with your own: - -```jsonc -"name": "cad-discord-your-name", -"CAD_BASE_URL": "https://your-docs.example.com", -"DISCORD_CLIENT_ID": "YOUR_DISCORD_APPLICATION_ID" +```bash +git clone https://github.com/ctrl-alt-doc/ctrlaltbot.git +cp -r ctrlaltbot/template my-bot +cd my-bot +npm install ``` -### 5. Add the Discord secrets - -Run each command and paste the requested value: +Edit `vars` in `wrangler.jsonc`, then: ```bash +npx wrangler login npx wrangler secret put DISCORD_PUBLIC_KEY npx wrangler secret put DISCORD_TOKEN -``` - -### 6. Deploy - -```bash npx wrangler deploy ``` -Copy the Worker URL printed by Wrangler. +Wrangler creates the settings storage on the first deploy. Then connect Discord to the Worker URL as in step 3. To update, run `npm update ctrlaltbot && npx wrangler deploy`. -### 7. Connect Discord to the Worker - -In the Discord Developer Portal, open **General Information** and set **Interactions Endpoint URL** to the Worker URL. - -Discord should verify the endpoint. - -### 8. Register slash commands - -From a source checkout, create a `.env` file: - -```text -DISCORD_TOKEN=your-bot-token -DISCORD_CLIENT_ID=your-application-id -DISCORD_GUILD_ID=your-test-server-id -``` +Do not put `DISCORD_TOKEN` or `DISCORD_PUBLIC_KEY` in `wrangler.jsonc`, source control, or CI logs. -Then run: - -```bash -npx tsx src/register-commands.ts -``` - -`DISCORD_GUILD_ID` is recommended while testing because commands update immediately. Omit it when registering global commands for public use. - -### Test locally - -```bash -npx wrangler dev -``` - -Discord requires a public HTTPS URL for live interactions. Use a tunnel for local testing or deploy a separate test Worker with: - -```bash -npx wrangler deploy --name cad-discord-test -``` - -The npm package is a distribution artifact and is not a standalone bot server. Do not use `npm start` to run the production bot. - -### Other package managers - -npm is the documented and tested package manager: - -```bash -npm ci -``` - -pnpm, Yarn, and Bun may install the project dependencies, but npm remains the release source of truth. If npm warns that `esbuild` or `workerd` install scripts need approval, run: - -```bash -npm approve-scripts esbuild workerd -npm rebuild esbuild workerd -``` +## Configuration -## Requirements +| Name | Kind | Purpose | +| --- | --- | --- | +| `CAD_BASE_URL` | variable | Address of your ctrl alt doc site. | +| `DISCORD_CLIENT_ID` | variable | Discord Application ID. | +| `DISCORD_PUBLIC_KEY` | secret | Verifies that requests come from Discord. | +| `DISCORD_TOKEN` | secret | Registers and updates the slash commands. Without it, register them manually with `npx ctrlaltbot-register`. | +| `DISCORD_GUILD_ID` | variable, optional | Registers commands to one server instead of globally. Server commands update instantly, which is useful for testing. | +| `BRAND_AVATAR_URL` | variable, optional | Avatar shown in embed footers. | +| `BRAND_SETTINGS` | KV namespace | Per-server settings, and the record of which command definitions are registered. | -- Node.js with native `fetch` support -- A Discord application and bot -- A CAD documentation site exposing the bot API endpoints -- A Cloudflare account for Worker deployment -- A Cloudflare KV namespace bound as `BRAND_SETTINGS` +The bot checks its command definitions when a Worker instance handles its first interaction, and hourly through a Cron Trigger. It only contacts Discord when the definitions have changed. -### CI/CD deployment +## CAD requirements -For automated deployments, install Wrangler in the CI environment, authenticate with a Cloudflare API token, and store the Discord credentials as encrypted CI secrets. The deployment step is: +The configured CAD site must be running and reachable by the Worker. The bot expects these endpoints: -```bash -npx wrangler deploy +```text +GET /api/search?q= +GET /api/page?slug= +GET /api/suggest?q=&kind=page ``` -Do not put `DISCORD_TOKEN` or `DISCORD_PUBLIC_KEY` in `wrangler.jsonc`, source control, Docker images, or CI logs. Set them with `wrangler secret put` or the equivalent CI-managed Wrangler secret workflow. - -### Docker, traditional Node hosting, and other platforms - -There is currently no supported Docker image, Express server, Fastify server, traditional long-running Node process, or Discord Gateway client in this package. The Worker entrypoint exports `fetch` and expects Cloudflare Worker bindings, including KV. Do not run `node dist/index.js` or `npm start` as a production bot process. - -The code can be adapted to another HTTP runtime, but that requires replacing Wrangler bindings and providing an HTTPS endpoint that accepts Discord interactions. That is an integration port, not an installation option supported by this release. - -## Configuration +Search results may include an optional `heading` (`{ "id": "...", "title": "..." }`) naming the best-matching section, which the bot uses as the link anchor. -Copy the example environment file: +The page endpoint must return `toc` for heading autocomplete. For section results it should return the page's Markdown source as `markdown`; older CAD versions that only return rendered HTML as `content` still work, with plainer formatting. A missing page must return HTTP 404. -```bash -cp .env.example .env -``` - -Fill in: - -```text -DISCORD_TOKEN= # secret bot token -DISCORD_CLIENT_ID= # Discord application ID -DISCORD_GUILD_ID= # optional: use guild registration during development -CAD_BASE_URL=http://localhost:5173 +```json +{ + "title": "Markdown", + "description": "...", + "excerpt": "...", + "slug": "reference/markdown", + "toc": [{ "id": "headings", "title": "Headings", "level": 2 }], + "content": "

Headings

...", + "markdown": "## Headings\n\n..." +} ``` -Never commit `.env` or paste the token into chat, documentation, or source control. +Discord requires a reply within three seconds. If CAD answers within 2.5 seconds, the bot replies directly; otherwise it shows Discord's "thinking" state and edits the reply when CAD responds. CAD requests time out after 10 seconds, and the bot then returns a documentation-server error rather than waiting indefinitely. Autocomplete cannot be deferred, so its CAD requests give up after 2.5 seconds. ## Development -Install dependencies: +This repository contains the `ctrlaltbot` package and, in `template/`, the repository that the Deploy button copies for each operator. ```bash npm install -``` - -Build and type-check the bot: - -```bash npm run build -``` - -Run the automated client and formatter tests: - -```bash npm test ``` -Register the `/docs` command during development: +`wrangler.jsonc` at the repository root is a development configuration that runs the source directly: ```bash -npm run register:dev -``` - -When `DISCORD_GUILD_ID` is set, the script registers guild commands, which update immediately and are useful during development. When it is omitted, the script registers global commands for a public installation; Discord notes that global command updates can take longer to propagate. Do not omit the guild ID accidentally during local development. - -## Create a release tarball - -Create a versioned runtime archive after building and testing: - -```bash -npm run release +npx wrangler dev ``` -This creates: +Discord requires a public HTTPS URL for live interactions. Use a tunnel, or deploy a separate test Worker with `npx wrangler deploy --name ctrlaltbot-test`. Set `DISCORD_GUILD_ID` on test Workers so their commands register to your test server only. -```text -release/cad-discord-.tar.gz -``` +To register commands by hand, fill in `.env` from `.env.example` and run `npm run register:dev`. -The archive contains the compiled Worker, package metadata, Wrangler configuration, `.env.example`, README, and changelog. It does not contain `.env`, `node_modules`, or development tests. - -## Automated releases +### Releases GitHub Actions runs the build, tests, and npm package inspection for pushes and pull requests targeting `main`. @@ -265,40 +170,25 @@ git tag v1.0.3 git push origin v1.0.3 ``` -A matching `v` tag runs the release workflow. It creates the GitHub release tarball, publishes the package to npm, and attaches the tarball to the GitHub release. The repository must have an `NPM_TOKEN` Actions secret with permission to publish `cad-discord`. - -CAD requests have a bounded timeout. If the configured CAD server is unreachable or does not respond in time, the bot returns a generic documentation-server error rather than waiting indefinitely. - -The configured CAD site must be running and reachable by the Worker. The bot expects these endpoints: - -```text -GET /api/search?q= -GET /api/page?slug= -GET /api/suggest?q=&kind=page +A matching `v` tag runs the release workflow, which publishes the package to npm. Operators receive it through Dependabot. The repository must have an `NPM_TOKEN` Actions secret with permission to publish `ctrlaltbot`. -The page endpoint must return `toc` and `content` for heading autocomplete and section results: - -```json -{ - "title": "Markdown", - "description": "...", - "excerpt": "...", - "slug": "reference/markdown", - "toc": [{ "id": "headings", "title": "Headings", "level": 2 }], - "content": "## Headings\n\n..." -} -``` +Use semantic versioning carefully: minor and patch releases are merged into operators' bots automatically, so anything that requires operators to change their configuration must be a major release. ## Architecture -- `src/index.ts` verifies and routes Discord HTTP interactions. -- `src/register-commands.ts` registers the guild slash command. +- `src/index.ts` verifies and routes Discord HTTP interactions, and runs the scheduled command check. +- `src/commands.ts` defines the slash commands. +- `src/register-commands.ts` registers the commands manually (`ctrlaltbot-register`). - `src/cad/client.ts` owns HTTP communication with CAD. - `src/cad/types.ts` describes CAD response contracts. -- `src/discord/responses.ts` builds the branded embed and link-button payloads. +- `src/discord/responses.ts` builds the branded embed, menu, and link-button payloads. +- `src/discord/sections.ts` extracts a single section of a page for Discord. +- `src/discord/deferred.ts` defers slow replies and completes them through the interaction webhook. +- `src/discord/registration.ts` registers the commands with Discord when their definitions change. - `src/brand.ts` contains the shared brand constants and runtime host/avatar configuration. +- `template/` is the operator repository created by the Deploy button. -The bot requests only the `Guilds` gateway intent. It does not read arbitrary server messages and does not require the privileged Message Content intent. +The bot receives interactions over HTTP only. It does not connect to the Discord Gateway, does not read server messages, and does not require the privileged Message Content intent. ## Data and privacy boundaries @@ -308,7 +198,7 @@ The hosting environment and CAD server may retain normal process or HTTP access ## Self-hosting model -Each operator can run their own deployment with their own: +Each operator runs their own deployment with their own: - Discord application and bot token; - Discord server; @@ -318,4 +208,3 @@ Each operator can run their own deployment with their own: For the pink bot name in Discord, a server admin can give the bot a role with colour `#ff88c8`. The role must be positioned and permissioned appropriately; the bot does not attempt to change its own managed role. This project is not designed as a central SaaS bot. -# cad-bot diff --git a/bin/cad-discord.mjs b/bin/cad-discord.mjs deleted file mode 100755 index 0277db3..0000000 --- a/bin/cad-discord.mjs +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node - -import '../dist/index.js'; diff --git a/bin/cad-discord-register.mjs b/bin/ctrlaltbot-register.mjs similarity index 100% rename from bin/cad-discord-register.mjs rename to bin/ctrlaltbot-register.mjs diff --git a/package-lock.json b/package-lock.json index be61931..6d9c4c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,18 @@ { - "name": "cad-discord", - "version": "1.0.6", + "name": "ctrlaltbot", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "cad-discord", - "version": "1.0.6", + "name": "ctrlaltbot", + "version": "1.1.0", "license": "ISC", "dependencies": { - "discord-interactions": "^4.4.0", - "discord.js": "^14.27.0", - "dotenv": "^17.4.2" + "discord-interactions": "^4.4.0" }, "bin": { - "cad-discord": "bin/cad-discord.mjs", - "cad-discord-register": "bin/cad-discord-register.mjs" + "ctrlaltbot-register": "bin/ctrlaltbot-register.mjs" }, "devDependencies": { "@types/node": "^22.20.2", @@ -148,136 +145,6 @@ "node": ">=12" } }, - "node_modules/@discordjs/builders": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", - "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/formatters": "^0.6.2", - "@discordjs/util": "^1.2.0", - "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.40", - "fast-deep-equal": "^3.1.3", - "ts-mixer": "^6.0.4", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/collection": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", - "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=16.11.0" - } - }, - "node_modules/@discordjs/formatters": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", - "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", - "license": "Apache-2.0", - "dependencies": { - "discord-api-types": "^0.38.33" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.3.tgz", - "integrity": "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.2.0", - "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.5", - "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.50", - "magic-bytes.js": "^1.13.0", - "tslib": "^2.6.3", - "undici": "^6.27.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", - "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/util": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", - "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", - "license": "Apache-2.0", - "dependencies": { - "discord-api-types": "^0.38.33" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/ws": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", - "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/collection": "^2.1.0", - "@discordjs/rest": "^2.5.1", - "@discordjs/util": "^1.1.0", - "@sapphire/async-queue": "^1.5.2", - "@types/ws": "^8.5.10", - "@vladfrangu/async_event_emitter": "^2.2.4", - "discord-api-types": "^0.38.1", - "tslib": "^2.6.2", - "ws": "^8.17.0" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", - "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", @@ -1363,39 +1230,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@sapphire/async-queue": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", - "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@sapphire/shapeshift": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", - "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "lodash": "^4.17.21" - }, - "engines": { - "node": ">=v16" - } - }, - "node_modules/@sapphire/snowflake": { - "version": "3.5.5", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", - "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", @@ -1420,20 +1254,12 @@ "version": "22.20.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -1774,16 +1600,6 @@ "node": ">=16.20.0" } }, - "node_modules/@vladfrangu/async_event_emitter": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", - "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -1815,12 +1631,6 @@ "node": ">=8" } }, - "node_modules/discord-api-types": { - "version": "0.38.55", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.55.tgz", - "integrity": "sha512-ytuaRTzdnHUCXJ6KjL9MrItQX0xKncBKEeYI1Bst4+ud47eejH3cG6gaesYakjpPcUhh68XYS2YwGq3mmujFsA==", - "license": "MIT" - }, "node_modules/discord-interactions": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/discord-interactions/-/discord-interactions-4.4.0.tgz", @@ -1830,45 +1640,6 @@ "node": ">=18.4.0" } }, - "node_modules/discord.js": { - "version": "14.27.0", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.27.0.tgz", - "integrity": "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/builders": "^1.14.1", - "@discordjs/collection": "1.5.3", - "@discordjs/formatters": "^0.6.2", - "@discordjs/rest": "^2.6.2", - "@discordjs/util": "^1.2.0", - "@discordjs/ws": "^1.2.3", - "@sapphire/snowflake": "3.5.5", - "discord-api-types": "^0.38.49", - "fast-deep-equal": "3.1.3", - "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.13.0", - "tslib": "^2.6.3", - "undici": "^6.27.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -1921,12 +1692,6 @@ "@esbuild/win32-x64": "0.28.2" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1952,24 +1717,6 @@ "node": ">=6" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "license": "MIT" - }, - "node_modules/magic-bytes.js": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.1.tgz", - "integrity": "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw==", - "license": "MIT" - }, "node_modules/miniflare": { "version": "5.20260915.0-alpha", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260915.0-alpha.tgz", @@ -2110,17 +1857,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/ts-mixer": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", - "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", - "license": "MIT" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "dev": true, + "license": "0BSD", + "optional": true }, "node_modules/tsx": { "version": "4.23.13", @@ -2176,19 +1919,11 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, - "node_modules/undici": { - "version": "6.28.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", - "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/unenv": { @@ -2742,27 +2477,6 @@ "@esbuild/win32-x64": "0.28.1" } }, - "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", diff --git a/package.json b/package.json index 7ce2226..669946e 100644 --- a/package.json +++ b/package.json @@ -1,38 +1,48 @@ { - "name": "cad-discord", - "version": "1.0.6", - "description": "", + "name": "ctrlaltbot", + "version": "1.1.0", + "description": "Discord bot for ctrl alt doc documentation sites, running as a Cloudflare Worker.", + "type": "module", "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./commands": { + "types": "./dist/commands.d.ts", + "import": "./dist/commands.js" + } + }, "bin": { - "cad-discord": "bin/cad-discord.mjs", - "cad-discord-register": "bin/cad-discord-register.mjs" + "ctrlaltbot-register": "bin/ctrlaltbot-register.mjs" }, "files": [ "dist/", "bin/", "README.md", - ".env.example", - "package.json", - "wrangler.jsonc", "CHANGELOG.md" ], "scripts": { "build": "tsc", "test": "node --import tsx --test \"test/**/*.test.ts\"", "release": "npm run build && npm test && node scripts/create-release.mjs", - "dev": "tsx src/index.ts", + "dev": "wrangler dev", "register": "node dist/register-commands.js", - "register:dev": "tsx src/register-commands.ts", - "start": "node dist/index.js" + "register:dev": "tsx src/register-commands.ts" }, - "keywords": [], + "keywords": [ + "discord", + "discord-bot", + "cloudflare-workers", + "documentation", + "ctrl-alt-doc" + ], "author": "", "license": "ISC", - "type": "module", "dependencies": { - "discord-interactions": "^4.4.0", - "discord.js": "^14.27.0", - "dotenv": "^17.4.2" + "discord-interactions": "^4.4.0" }, "devDependencies": { "@types/node": "^22.20.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30c29ed..db1b3fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,12 +11,6 @@ importers: discord-interactions: specifier: ^4.4.0 version: 4.4.0 - discord.js: - specifier: ^14.27.0 - version: 14.27.0 - dotenv: - specifier: ^17.4.2 - version: 17.4.2 devDependencies: '@types/node': specifier: ^22.20.2 @@ -80,34 +74,6 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@discordjs/builders@1.14.1': - resolution: {integrity: sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==} - engines: {node: '>=16.11.0'} - - '@discordjs/collection@1.5.3': - resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==} - engines: {node: '>=16.11.0'} - - '@discordjs/collection@2.1.1': - resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} - engines: {node: '>=18'} - - '@discordjs/formatters@0.6.2': - resolution: {integrity: sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==} - engines: {node: '>=16.11.0'} - - '@discordjs/rest@2.6.3': - resolution: {integrity: sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==} - engines: {node: '>=18'} - - '@discordjs/util@1.2.0': - resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==} - engines: {node: '>=18'} - - '@discordjs/ws@1.2.3': - resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==} - engines: {node: '>=16.11.0'} - '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} @@ -604,18 +570,6 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} - '@sapphire/async-queue@1.5.5': - resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==} - engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - - '@sapphire/shapeshift@4.0.0': - resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==} - engines: {node: '>=v16'} - - '@sapphire/snowflake@3.5.5': - resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==} - engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -626,9 +580,6 @@ packages: '@types/node@22.20.4': resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==} - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -749,10 +700,6 @@ packages: cpu: [x64] os: [win32] - '@vladfrangu/async_event_emitter@2.4.7': - resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} - engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} @@ -764,21 +711,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - discord-api-types@0.38.55: - resolution: {integrity: sha512-ytuaRTzdnHUCXJ6KjL9MrItQX0xKncBKEeYI1Bst4+ud47eejH3cG6gaesYakjpPcUhh68XYS2YwGq3mmujFsA==} - discord-interactions@4.4.0: resolution: {integrity: sha512-jjJx8iwAeJcj8oEauV43fue9lNqkf38fy60aSs2+G8D1nJmDxUIrk08o3h0F3wgwuBWWJUZO+X/VgfXsxpCiJA==} engines: {node: '>=18.4.0'} - discord.js@14.27.0: - resolution: {integrity: sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==} - engines: {node: '>=18'} - - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} - error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -792,9 +728,6 @@ packages: engines: {node: '>=18'} hasBin: true - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -804,15 +737,6 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - lodash.snakecase@4.1.1: - resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} - - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - - magic-bytes.js@1.13.1: - resolution: {integrity: sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw==} - miniflare@5.20260918.0-alpha: resolution: {integrity: sha512-vyIes7yW/OTzHtz4GhcBCTohZfqk2eQNiIkngZ07VRA5Qu24aNgW/TrFwlPkMLMjWDQ8R4JJBBHR/KX+liSDtg==} engines: {node: '>=22.0.0'} @@ -841,9 +765,6 @@ packages: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} - ts-mixer@6.0.4: - resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -860,10 +781,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@6.28.1: - resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==} - engines: {node: '>=18.17'} - undici@7.29.0: resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} @@ -898,18 +815,6 @@ packages: utf-8-validate: optional: true - ws@8.21.3: - resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -945,55 +850,6 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@discordjs/builders@1.14.1': - dependencies: - '@discordjs/formatters': 0.6.2 - '@discordjs/util': 1.2.0 - '@sapphire/shapeshift': 4.0.0 - discord-api-types: 0.38.55 - fast-deep-equal: 3.1.3 - ts-mixer: 6.0.4 - tslib: 2.8.1 - - '@discordjs/collection@1.5.3': {} - - '@discordjs/collection@2.1.1': {} - - '@discordjs/formatters@0.6.2': - dependencies: - discord-api-types: 0.38.55 - - '@discordjs/rest@2.6.3': - dependencies: - '@discordjs/collection': 2.1.1 - '@discordjs/util': 1.2.0 - '@sapphire/async-queue': 1.5.5 - '@sapphire/snowflake': 3.5.5 - '@vladfrangu/async_event_emitter': 2.4.7 - discord-api-types: 0.38.55 - magic-bytes.js: 1.13.1 - tslib: 2.8.1 - undici: 6.28.1 - - '@discordjs/util@1.2.0': - dependencies: - discord-api-types: 0.38.55 - - '@discordjs/ws@1.2.3': - dependencies: - '@discordjs/collection': 2.1.1 - '@discordjs/rest': 2.6.3 - '@discordjs/util': 1.2.0 - '@sapphire/async-queue': 1.5.5 - '@types/ws': 8.18.1 - '@vladfrangu/async_event_emitter': 2.4.7 - discord-api-types: 0.38.55 - tslib: 2.8.1 - ws: 8.21.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 @@ -1282,15 +1138,6 @@ snapshots: '@poppinss/exception@1.2.3': {} - '@sapphire/async-queue@1.5.5': {} - - '@sapphire/shapeshift@4.0.0': - dependencies: - fast-deep-equal: 3.1.3 - lodash: 4.18.1 - - '@sapphire/snowflake@3.5.5': {} - '@sindresorhus/is@7.2.0': {} '@speed-highlight/core@1.2.24': {} @@ -1299,10 +1146,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/ws@8.18.1': - dependencies: - '@types/node': 22.20.4 - '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -1363,39 +1206,14 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vladfrangu/async_event_emitter@2.4.7': {} - blake3-wasm@2.1.5: {} cookie@1.1.1: {} detect-libc@2.1.2: {} - discord-api-types@0.38.55: {} - discord-interactions@4.4.0: {} - discord.js@14.27.0: - dependencies: - '@discordjs/builders': 1.14.1 - '@discordjs/collection': 1.5.3 - '@discordjs/formatters': 0.6.2 - '@discordjs/rest': 2.6.3 - '@discordjs/util': 1.2.0 - '@discordjs/ws': 1.2.3 - '@sapphire/snowflake': 3.5.5 - discord-api-types: 0.38.55 - fast-deep-equal: 3.1.3 - lodash.snakecase: 4.1.1 - magic-bytes.js: 1.13.1 - tslib: 2.8.1 - undici: 6.28.1 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - dotenv@17.4.2: {} - error-stack-parser-es@1.0.5: {} esbuild@0.28.1: @@ -1456,19 +1274,11 @@ snapshots: '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 - fast-deep-equal@3.1.3: {} - fsevents@2.3.3: optional: true kleur@4.1.5: {} - lodash.snakecase@4.1.1: {} - - lodash@4.18.1: {} - - magic-bytes.js@1.13.1: {} - miniflare@5.20260918.0-alpha(@types/node@22.20.4): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -1523,9 +1333,8 @@ snapshots: supports-color@10.2.2: {} - ts-mixer@6.0.4: {} - - tslib@2.8.1: {} + tslib@2.8.1: + optional: true tsx@4.23.15: dependencies: @@ -1558,8 +1367,6 @@ snapshots: undici-types@6.21.0: {} - undici@6.28.1: {} - undici@7.29.0: {} unenv@2.0.0-rc.24: @@ -1593,8 +1400,6 @@ snapshots: ws@8.21.0: {} - ws@8.21.3: {} - youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 diff --git a/scripts/create-release.mjs b/scripts/create-release.mjs index 89553cb..516b60a 100644 --- a/scripts/create-release.mjs +++ b/scripts/create-release.mjs @@ -11,7 +11,7 @@ const packageJson = JSON.parse( const archiveName = `${packageJson.name}-${packageJson.version}.tar.gz`; const releaseDirectory = join(projectRoot, 'release'); const archivePath = join(releaseDirectory, archiveName); -const stagingRoot = await mkdtemp(join(tmpdir(), 'cad-discord-release-')); +const stagingRoot = await mkdtemp(join(tmpdir(), `${packageJson.name}-release-`)); const releaseRoot = join(stagingRoot, `${packageJson.name}-${packageJson.version}`); await mkdir(releaseRoot, { recursive: true }); diff --git a/src/cad/client.ts b/src/cad/client.ts index 4e7d073..3fa8bb3 100644 --- a/src/cad/client.ts +++ b/src/cad/client.ts @@ -8,95 +8,46 @@ import type { const CAD_REQUEST_TIMEOUT_MS = 10_000; export class CadClient { - constructor(private readonly baseUrl: string) {} + constructor( + private readonly baseUrl: string, + private readonly timeoutMs = CAD_REQUEST_TIMEOUT_MS + ) {} - async search(query: string): Promise { - const url = new URL('/api/search', this.baseUrl); - url.searchParams.set('q', query); - - let response: Response; - - try { - response = await fetch(url, { - signal: AbortSignal.timeout(CAD_REQUEST_TIMEOUT_MS) - }); - } catch { - throw new Error('CAD_UNREACHABLE'); - } - - if (!response.ok) { - throw new Error(`CAD_HTTP_${response.status}`); - } - - try { - return await response.json() as SearchResult[]; - } catch { - throw new Error('CAD_INVALID_RESPONSE'); - } + search(query: string): Promise { + return this.request('/api/search', { q: query }); } - async getPage(slug: string): Promise { - const url = new URL('/api/page', this.baseUrl); - url.searchParams.set('slug', slug); + getPage(slug: string): Promise { + return this.request('/api/page', { slug }); + } - let response: Response; + listPages(category: string): Promise { + return this.request('/api/list', { category }); + } - try { - response = await fetch(url, { - signal: AbortSignal.timeout(CAD_REQUEST_TIMEOUT_MS) - }); - } catch { - throw new Error('CAD_UNREACHABLE'); - } + suggest(query: string, kind: 'page' | 'category'): Promise { + return this.request('/api/suggest', { q: query, kind }); + } - if (!response.ok) { - throw new Error(`CAD_HTTP_${response.status}`); - } + private async request(path: string, params: Record): Promise { + const url = new URL(path, this.baseUrl); - try { - return await response.json() as PageResult; - } catch { - throw new Error('CAD_INVALID_RESPONSE'); + for (const [name, value] of Object.entries(params)) { + url.searchParams.set(name, value); } - } - async listPages(category: string): Promise { - const url = new URL('/api/list', this.baseUrl); - url.searchParams.set('category', category); let response: Response; try { response = await fetch(url, { - signal: AbortSignal.timeout(CAD_REQUEST_TIMEOUT_MS) + signal: AbortSignal.timeout(this.timeoutMs) }); } catch { throw new Error('CAD_UNREACHABLE'); } - if (!response.ok) { - throw new Error(`CAD_HTTP_${response.status}`); - } - - try { - return await response.json() as PageListResult; - } catch { - throw new Error('CAD_INVALID_RESPONSE'); - } - } - - async suggest(query: string, kind: 'page' | 'category'): Promise { - const url = new URL('/api/suggest', this.baseUrl); - url.searchParams.set('q', query); - url.searchParams.set('kind', kind); - - let response: Response; - - try { - response = await fetch(url, { - signal: AbortSignal.timeout(CAD_REQUEST_TIMEOUT_MS) - }); - } catch { - throw new Error('CAD_UNREACHABLE'); + if (response.status === 404) { + throw new Error('CAD_NOT_FOUND'); } if (!response.ok) { @@ -104,7 +55,7 @@ export class CadClient { } try { - return await response.json() as SuggestionResult[]; + return await response.json() as T; } catch { throw new Error('CAD_INVALID_RESPONSE'); } diff --git a/src/cad/types.ts b/src/cad/types.ts index 8badb79..d91b3ff 100644 --- a/src/cad/types.ts +++ b/src/cad/types.ts @@ -6,6 +6,12 @@ export interface SearchResult { section?: string; breadcrumb?: string[]; sinceVersion?: string; + heading?: SearchResultHeading; +} + +export interface SearchResultHeading { + id: string; + title: string; } export interface PageResult { @@ -17,7 +23,10 @@ export interface PageResult { breadcrumb?: string[]; sinceVersion?: string; toc?: TocItem[]; + /** Rendered HTML. */ content?: string; + /** Markdown source, preferred for section text when CAD provides it. */ + markdown?: string; } export interface TocItem { diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..4104dd9 --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,65 @@ +// Discord application command definitions, shared by the Worker's automatic +// registration and the manual `ctrlaltbot-register` script. +// https://discord.com/developers/docs/interactions/application-commands + +const CHAT_INPUT = 1; +const SUB_COMMAND = 1; +const SUB_COMMAND_GROUP = 2; +const STRING = 3; + +export const COMMANDS = [ + { + type: CHAT_INPUT, + name: 'docs', + description: 'Search and browse the documentation', + options: [ + { + type: SUB_COMMAND, + name: 'search', + description: 'Search the documentation', + options: [ + { type: STRING, name: 'query', description: 'What do you want to find?', required: true, autocomplete: true } + ] + }, + { + type: SUB_COMMAND, + name: 'page', + description: 'Open a documentation page', + options: [ + { type: STRING, name: 'slug', description: 'The documentation page slug', required: true, autocomplete: true }, + { type: STRING, name: 'header', description: 'A heading within the documentation page', required: false, autocomplete: true } + ] + }, + { + type: SUB_COMMAND_GROUP, + name: 'settings', + description: 'Configure this server’s documentation bot', + options: [ + { + type: SUB_COMMAND, + name: 'colour', + description: 'Set the embed colour for this server', + options: [ + { type: STRING, name: 'hex', description: 'Six-digit hex colour, for example #ce0985', required: true } + ] + } + ] + } + ] + }, + { + type: CHAT_INPUT, + name: 'ask', + description: 'Ask the documentation a question', + options: [ + { type: STRING, name: 'question', description: 'For example: how do I add a callout?', required: true, autocomplete: true } + ] + } +]; + +/** A stable fingerprint of the command definitions, used to skip registration when nothing changed. */ +export async function commandsHash(commands: unknown = COMMANDS): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(commands))); + + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/src/discord/deferred.ts b/src/discord/deferred.ts new file mode 100644 index 0000000..153851f --- /dev/null +++ b/src/discord/deferred.ts @@ -0,0 +1,85 @@ +import { EPHEMERAL, type MessageData } from './responses.js'; + +const DISCORD_API = 'https://discord.com/api/v10'; + +// Discord fails any interaction that isn't answered within three seconds. +export const DEFER_AFTER_MS = 2_500; + +export interface ExecutionContext { + waitUntil(promise: Promise): void; +} + +export interface InteractionWebhook { + application_id: string; + token: string; +} + +export interface RespondOptions { + /** Replace the message a component belongs to, instead of sending a new reply. Ephemeral messages are still sent as new replies. */ + updatesMessage?: boolean; + deferAfterMs?: number; +} + +const isEphemeral = (message: MessageData) => ((message.flags ?? 0) & EPHEMERAL) === EPHEMERAL; + +/** + * Replies directly when the message is ready in time; otherwise sends a + * deferred response and completes it once the message is ready. + */ +export async function respondInTime( + message: Promise, + interaction: InteractionWebhook, + ctx: ExecutionContext, + { updatesMessage = false, deferAfterMs = DEFER_AFTER_MS }: RespondOptions = {} +): Promise { + let timer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(resolve, deferAfterMs, undefined); + }); + + const early = await Promise.race([message, deadline]); + clearTimeout(timer); + + if (early) { + return Response.json({ type: updatesMessage && !isEphemeral(early) ? 7 : 4, data: early }); + } + + ctx.waitUntil(message.then((data) => sendDeferredReply(interaction, data, updatesMessage))); + + // 6 keeps the current message while working; 5 shows "thinking" as a new reply. + return Response.json({ type: updatesMessage ? 6 : 5 }); +} + +/** + * Completes a deferred interaction. A deferred reply's visibility is fixed when + * it is sent, so ephemeral messages go out as a follow-up and any public + * "thinking" message is removed. + */ +export async function sendDeferredReply(interaction: InteractionWebhook, message: MessageData, updatesMessage = false): Promise { + const webhook = `${DISCORD_API}/webhooks/${interaction.application_id}/${interaction.token}`; + + if (isEphemeral(message)) { + await discordRequest(webhook, 'POST', message); + // When updating, @original is the message the component belongs to, which must stay. + if (!updatesMessage) await discordRequest(`${webhook}/messages/@original`, 'DELETE'); + return; + } + + const { flags: _flags, ...body } = message; + await discordRequest(`${webhook}/messages/@original`, 'PATCH', body); +} + +async function discordRequest(url: string, method: string, body?: unknown): Promise { + try { + const response = await fetch(url, { + method, + ...(body === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + }); + + if (!response.ok) { + console.error(`Discord ${method} failed:`, response.status, await response.text()); + } + } catch (error) { + console.error(`Discord ${method} failed:`, error); + } +} diff --git a/src/discord/registration.ts b/src/discord/registration.ts new file mode 100644 index 0000000..01ef6f3 --- /dev/null +++ b/src/discord/registration.ts @@ -0,0 +1,67 @@ +import { COMMANDS, commandsHash } from '../commands.js'; + +const DISCORD_API = 'https://discord.com/api/v10'; +const HASH_KEY = 'commands:hash'; + +export interface RegistrationStore { + get(key: string): Promise; + put(key: string, value: string): Promise; +} + +export interface RegistrationEnv { + DISCORD_TOKEN?: string; + DISCORD_CLIENT_ID?: string; + /** Registers to one server instead of globally, for development: guild commands update instantly. */ + DISCORD_GUILD_ID?: string; +} + +export type RegistrationResult = 'registered' | 'unchanged' | 'not-configured' | 'failed'; + +export function commandsUrl({ DISCORD_CLIENT_ID, DISCORD_GUILD_ID }: RegistrationEnv): string { + return DISCORD_GUILD_ID + ? `${DISCORD_API}/applications/${DISCORD_CLIENT_ID}/guilds/${DISCORD_GUILD_ID}/commands` + : `${DISCORD_API}/applications/${DISCORD_CLIENT_ID}/commands`; +} + +/** Replaces all of the application's commands with the current definitions. */ +export async function registerCommands(env: RegistrationEnv): Promise { + return fetch(commandsUrl(env), { + method: 'PUT', + headers: { Authorization: `Bot ${env.DISCORD_TOKEN}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(COMMANDS) + }); +} + +/** + * Registers the commands with Discord when their definitions have changed since + * the last registration, so deploying an update is enough to publish new commands. + */ +export async function syncCommands(env: RegistrationEnv, store: RegistrationStore): Promise { + if (!env.DISCORD_TOKEN || !env.DISCORD_CLIENT_ID) { + return 'not-configured'; + } + + // The target is part of the key, so switching between guild and global registration re-registers. + const hash = `${await commandsHash()}:${env.DISCORD_GUILD_ID ?? 'global'}`; + + if (await store.get(HASH_KEY) === hash) { + return 'unchanged'; + } + + try { + const response = await registerCommands(env); + + if (!response.ok) { + console.error('Command registration failed:', response.status, await response.text()); + return 'failed'; + } + } catch (error) { + console.error('Command registration failed:', error); + return 'failed'; + } + + await store.put(HASH_KEY, hash); + console.log('Registered updated application commands.'); + + return 'registered'; +} diff --git a/src/discord/responses.ts b/src/discord/responses.ts index 863e2b9..38fe2cb 100644 --- a/src/discord/responses.ts +++ b/src/discord/responses.ts @@ -1,8 +1,44 @@ -import type { ListResult, PageResult, SearchResult, TocItem } from '../cad/types.js'; +import type { ListResult, PageResult, SearchResult } from '../cad/types.js'; import { BRAND, type BrandConfig } from '../brand.js'; +import { sectionText } from './sections.js'; + +export interface MessageData { + content?: string; + flags?: number; + embeds?: unknown[]; + components?: unknown[]; +} + +export const EPHEMERAL = 64; + +export const SEARCH_SELECT_ID = 'search-results'; +const MAX_SELECT_OPTIONS = 10; + +interface SelectOption { + label: string; + value: string; + description?: string; + default?: boolean; +} + +interface SelectRow { + type: 1; + components: { type: 3; custom_id: string; placeholder: string; options: SelectOption[] }[]; +} const truncate = (value: string, length: number) => value.length <= length ? value : `${value.slice(0, length - 1).trimEnd()}…`; -const pageUrl = (slug: string, baseUrl: string) => new URL(`/${slug}`, baseUrl).toString(); +const pageUrl = (slug: string, baseUrl: string, anchor?: string) => new URL(`/${slug}${anchor ? `#${anchor}` : ''}`, baseUrl).toString(); + +// Discord rejects the whole autocomplete response if any choice exceeds these limits. +const MAX_CHOICES = 25; +const MAX_CHOICE_LENGTH = 100; + +export function autocompleteChoices(choices: { name: string; value: string }[]) { + return choices + .filter((choice) => choice.name && choice.value && choice.value.length <= MAX_CHOICE_LENGTH) + .slice(0, MAX_CHOICES) + .map((choice) => ({ name: truncate(choice.name, MAX_CHOICE_LENGTH), value: choice.value })); +} export interface DocResult { title: string; @@ -13,13 +49,13 @@ export interface DocResult { sinceVersion?: string; } -export function docEmbed(doc: DocResult, brand: BrandConfig) { +export function docEmbed(doc: DocResult, brand: BrandConfig, summaryLength = 200) { const fields = [ { name: 'Section', value: truncate(doc.breadcrumb.join(' › ') || doc.section, 1024), inline: true }, ...(doc.sinceVersion ? [{ name: 'Version', value: `\`${truncate(doc.sinceVersion, 100)}+\``, inline: true }] : []) ]; return { - embeds: [{ color: brand.color, author: { name: truncate(`${BRAND.product} · ${doc.section}`, 256) }, title: truncate(doc.title, 256), description: truncate(doc.summary || 'Open the page for the full documentation.', 200), fields, footer: { text: truncate(`${BRAND.name} · ${brand.docsHost}`, 2048), icon_url: brand.avatarUrl } }], + embeds: [{ color: brand.color, author: { name: truncate(`${BRAND.product} · ${doc.section}`, 256) }, title: truncate(doc.title, 256), description: truncate(doc.summary || 'Open the page for the full documentation.', summaryLength), fields, footer: { text: truncate(`${BRAND.name} · ${brand.docsHost}`, 2048), icon_url: brand.avatarUrl } }], components: [{ type: 1, components: [{ type: 2, style: 5, label: 'Open page', url: doc.url }] }] }; } @@ -29,56 +65,114 @@ export function formatPageResult(page: PageResult, baseUrl: string, brand: Brand const doc = toDoc(page, baseUrl, page.section ?? 'Docs'); if (selected) { - doc.title = selected.title; - doc.summary = sectionContent(page.content ?? '', page.toc ?? [], selected); + doc.title = `${page.title} › ${selected.title}`; + doc.summary = sectionText(page, selected) ?? 'Open the page to read this section.'; doc.url = `${doc.url}#${selected.id}`; } - return docEmbed(doc, brand); + // Embed descriptions allow 4096 characters; sectionText keeps sections well under that. + return docEmbed(doc, brand, selected ? 4096 : undefined); } export function headerChoices(page: PageResult, query: string) { - return (page.toc ?? []) + return autocompleteChoices((page.toc ?? []) .filter((item) => item.title.toLowerCase().includes(query.toLowerCase())) - .slice(0, 25) - .map((item) => ({ name: item.title, value: item.id })); + .map((item) => ({ name: item.title, value: item.id }))); } -export function formatSearchResults(results: SearchResult[], baseUrl: string, query: string, brand: BrandConfig) { +/** Keeps the typed question as the first choice so pressing Enter searches for it as written. */ +export function queryChoices(query: string, titles: string[]) { + const typed = query.trim(); + const typedChoice = typed ? [{ name: `Search: ${typed}`, value: truncate(typed, MAX_CHOICE_LENGTH) }] : []; + + return autocompleteChoices([ + ...typedChoice, + ...titles.filter((title) => title.toLowerCase() !== typed.toLowerCase()).map((title) => ({ name: title, value: title })) + ]); +} + +/** + * Shows the top result with a menu for switching to the others. `topPage` is the + * top result's full page, when fetched, so a matched heading can show its section. + */ +export function formatSearchResults(results: SearchResult[], baseUrl: string, query: string, brand: BrandConfig, topPage?: PageResult) { if (!results.length) return noResultsEmbed(query, baseUrl, brand); - const response = docEmbed(toDoc(results[0]!, baseUrl, results[0]!.section ?? 'Docs'), brand); - const additional = results.slice(1, 5); - if (additional.length) response.embeds[0]!.fields!.push({ name: 'Also matching', value: additional.map((result) => `[${truncate(result.title, 256)}](${pageUrl(result.slug, baseUrl)})`).join('\n'), inline: false }); - return response; + const top = results[0]!; + let message; + if (topPage) { + message = formatPageResult(topPage, baseUrl, brand, top.heading?.id); + } else { + const doc = toDoc(top, baseUrl, top.section ?? 'Docs'); + if (top.heading) { + doc.title = resultTitle(top); + doc.url = pageUrl(top.slug, baseUrl, top.heading.id); + } + message = docEmbed(doc, brand); + } + const select = searchResultSelect(results, resultValue(top.slug, top.heading?.id)); + return { ...message, components: [...(select ? [select] : []), ...message.components] }; } -export function errorResponse(brand: BrandConfig, explanation: string) { - return { type: 4, data: { flags: 64, embeds: [{ color: brand.color, title: "Couldn't reach the docs", description: explanation, footer: { text: `${BRAND.name} · ${brand.docsHost}`, icon_url: brand.avatarUrl } }] } }; +/** A search result chosen from the menu, keeping the menu with the new choice selected. */ +export function formatSelectedResult(page: PageResult, baseUrl: string, brand: BrandConfig, value: string, previousComponents: unknown) { + const message = formatPageResult(page, baseUrl, brand, parseResultValue(value).header); + const select = reselect(previousComponents, value); + return { ...message, components: [...(select ? [select] : []), ...message.components] }; } -function noResultsEmbed(query: string, baseUrl: string, brand: BrandConfig) { - return { embeds: [{ color: brand.color, author: { name: `${BRAND.product} · Search` }, title: truncate(`No matches for "${query}"`, 256), description: 'Try a broader search term or browse the documentation.', footer: { text: `${BRAND.name} · ${brand.docsHost}`, icon_url: brand.avatarUrl } }], components: [{ type: 1, components: [{ type: 2, style: 5, label: 'Browse the docs', url: new URL('/', baseUrl).toString() }] }] }; +export const resultValue = (slug: string, headingId?: string) => headingId ? `${slug}#${headingId}` : slug; + +export function parseResultValue(value: string): { slug: string; header?: string } { + const [slug = '', header] = value.split(/#(.*)/s); + return header ? { slug, header } : { slug }; } -function toDoc(item: PageResult | SearchResult | ListResult, baseUrl: string, fallbackSection: string, fallbackBreadcrumb?: string[]): DocResult { - const doc: DocResult = { title: item.title, section: item.section ?? fallbackSection, breadcrumb: item.breadcrumb ?? fallbackBreadcrumb ?? [item.section ?? fallbackSection], summary: 'description' in item ? item.description || item.excerpt : '', url: pageUrl(item.slug, baseUrl) }; - if (item.sinceVersion) doc.sinceVersion = item.sinceVersion; - return doc; +const resultTitle = (result: SearchResult) => result.heading ? `${result.title} › ${result.heading.title}` : result.title; + +function searchResultSelect(results: SearchResult[], selectedValue: string): SelectRow | undefined { + const seen = new Set(); + const options: SelectOption[] = []; + + for (const result of results) { + const value = resultValue(result.slug, result.heading?.id); + if (value.length > MAX_CHOICE_LENGTH || seen.has(value)) continue; + seen.add(value); + + const option: SelectOption = { label: truncate(resultTitle(result), MAX_CHOICE_LENGTH), value, default: value === selectedValue }; + if (result.description) option.description = truncate(result.description, MAX_CHOICE_LENGTH); + options.push(option); + + if (options.length === MAX_SELECT_OPTIONS) break; + } + + return options.length > 1 ? selectRow(options) : undefined; +} + +function reselect(components: unknown, value: string): SelectRow | undefined { + if (!Array.isArray(components)) return undefined; + + for (const row of components as { components?: { custom_id?: string; options?: SelectOption[] }[] }[]) { + const select = row.components?.find((component) => component.custom_id === SEARCH_SELECT_ID); + if (select?.options) return selectRow(select.options.map((option) => ({ ...option, default: option.value === value }))); + } + + return undefined; } -function sectionContent(content: string, toc: TocItem[], selected: TocItem) { - if (!content) return `Section: ${selected.title}`; +function selectRow(options: SelectOption[]): SelectRow { + return { type: 1, components: [{ type: 3, custom_id: SEARCH_SELECT_ID, placeholder: 'Other matching pages', options }] }; +} - const heading = new RegExp(`^#{${selected.level}}\\s+${escapeRegExp(selected.title)}\\s*$`, 'im'); - const start = content.search(heading); - if (start < 0) return `Section: ${selected.title}`; +export function errorMessage(brand: BrandConfig, explanation: string, title = "Couldn't reach the docs"): MessageData { + return { flags: EPHEMERAL, embeds: [{ color: brand.color, title, description: explanation, footer: { text: `${BRAND.name} · ${brand.docsHost}`, icon_url: brand.avatarUrl } }] }; +} - const afterHeading = content.slice(start).replace(/^.*\n/, ''); - const next = toc.find((item) => item.level <= selected.level && item.id !== selected.id && content.indexOf(`#${'#'.repeat(item.level - 1)} ${item.title}`, start + 1) > start); - const end = next ? content.indexOf(`#${'#'.repeat(next.level - 1)} ${next.title}`, start + 1) : afterHeading.length; - return afterHeading.slice(0, end < 0 ? undefined : end).replace(/[#*_`]/g, '').trim(); +function noResultsEmbed(query: string, baseUrl: string, brand: BrandConfig) { + return { embeds: [{ color: brand.color, author: { name: `${BRAND.product} · Search` }, title: truncate(`No matches for "${query}"`, 256), description: 'Try a broader search term or browse the documentation.', footer: { text: `${BRAND.name} · ${brand.docsHost}`, icon_url: brand.avatarUrl } }], components: [{ type: 1, components: [{ type: 2, style: 5, label: 'Browse the docs', url: new URL('/', baseUrl).toString() }] }] }; } -function escapeRegExp(value: string) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +function toDoc(item: PageResult | SearchResult | ListResult, baseUrl: string, fallbackSection: string, fallbackBreadcrumb?: string[]): DocResult { + const doc: DocResult = { title: item.title, section: item.section ?? fallbackSection, breadcrumb: item.breadcrumb ?? fallbackBreadcrumb ?? [item.section ?? fallbackSection], summary: 'description' in item ? item.description || item.excerpt : '', url: pageUrl(item.slug, baseUrl) }; + if (item.sinceVersion) doc.sinceVersion = item.sinceVersion; + return doc; } diff --git a/src/discord/sections.ts b/src/discord/sections.ts new file mode 100644 index 0000000..335a7e5 --- /dev/null +++ b/src/discord/sections.ts @@ -0,0 +1,127 @@ +import type { PageResult, TocItem } from '../cad/types.js'; + +const MAX_SECTION_LENGTH = 1500; + +const HTML_ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', '#039': "'", '#39': "'", nbsp: ' ' }; + +// 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. */ +export function sectionText(page: PageResult, selected: TocItem): string | undefined { + const text = page.markdown !== undefined + ? fromMarkdown(page.markdown, page.toc ?? [], selected) + : page.content ? fromHtml(page.content, selected) : undefined; + + return text ? truncateMarkdown(text, MAX_SECTION_LENGTH) : undefined; +} + +function fromMarkdown(markdown: string, toc: TocItem[], selected: TocItem): string | undefined { + // Pages can repeat a heading, so find the same occurrence the table of contents refers to. + const index = toc.findIndex((item) => item.id === selected.id); + const occurrence = toc + .slice(0, Math.max(index, 0)) + .filter((item) => item.level === selected.level && plain(item.title) === plain(selected.title)).length; + + const lines = markdown.split('\n'); + let fence: string | undefined; + let seen = 0; + let start = -1; + let end = lines.length; + + for (const [lineIndex, line] of lines.entries()) { + const fenceMatch = /^\s*(```|~~~)/.exec(line); + if (fenceMatch) { + if (!fence) fence = fenceMatch[1]; + else if (line.trim().startsWith(fence)) fence = undefined; + continue; + } + if (fence) continue; + + const heading = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line); + if (!heading) continue; + + const level = heading[1]!.length; + if (start < 0) { + if (level === selected.level && plain(heading[2]!) === plain(selected.title) && seen++ === occurrence) start = lineIndex; + } else if (level <= selected.level) { + end = lineIndex; + break; + } + } + + return start < 0 ? undefined : toDiscordMarkdown(lines.slice(start + 1, end)); +} + +/** Discord embeds render most Markdown, but not headings, directives, or embedded components. */ +function toDiscordMarkdown(lines: string[]): string { + const output: string[] = []; + let inFence = false; + + for (const line of lines) { + if (/^\s*(```|~~~)/.test(line)) inFence = !inFence; + + if (inFence || /^\s*(```|~~~)/.test(line)) { + output.push(line); + continue; + } + + const heading = /^#{1,6}\s+(.+?)\s*#*\s*$/.exec(line); + // `:::tip[Title]` opens a block labelled "Title", `:::tip` one labelled "Tip"; a bare `:::` closes it. + const directive = /^\s*:::(\w*)(?:\[(.*)\])?/.exec(line); + + if (heading) output.push(`**${heading[1]}**`); + else if (directive) { + const [, name = '', title] = directive; + const label = title || (name && `${name[0]!.toUpperCase()}${name.slice(1)}`); + if (label) output.push(`**${label}**`); + } else if (!/^\s*]*\\bid="${escapeRegExp(selected.id)}"`, 'i'); + const start = html.search(open); + if (start < 0) return undefined; + + const closingTag = ``; + const headingEnd = html.indexOf(closingTag, start); + if (headingEnd < 0) return undefined; + + const rest = html.slice(headingEnd + closingTag.length); + const next = rest.search(new RegExp(`/gi, '') + .replace(/[\s\S]*?<\/span>/gi, '') + .replace(/<\/(p|div|li|pre|tr|h\d)>|/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/&(#?\w+);/g, (entity, name: string) => HTML_ENTITIES[name] ?? entity) + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** Truncates without leaving a code block open, which would break the rest of the embed. */ +export function truncateMarkdown(text: string, length: number): string { + if (text.length <= length) return text; + + const reserve = 8; + let cut = text.slice(0, length - reserve); + const lastBreak = Math.max(cut.lastIndexOf('\n'), cut.lastIndexOf(' ')); + if (lastBreak > cut.length / 2) cut = cut.slice(0, lastBreak); + cut = cut.trimEnd(); + + const openFence = (cut.match(/```/g)?.length ?? 0) % 2 === 1; + + return openFence ? `${cut}\n\`\`\`\n…` : `${cut}…`; +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/index.ts b/src/index.ts index 956d25c..86470d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,280 +1,322 @@ import { verifyKey } from 'discord-interactions'; import { CadClient } from './cad/client.js'; +import type { PageResult } from './cad/types.js'; import { formatPageResult, formatSearchResults, - errorResponse, - headerChoices + formatSelectedResult, + parseResultValue, + SEARCH_SELECT_ID, + errorMessage, + autocompleteChoices, + headerChoices, + queryChoices, + EPHEMERAL, + type MessageData } from './discord/responses.js'; -import { brandFor, parseBrandColor } from './brand.js'; +import { DEFER_AFTER_MS, respondInTime, type ExecutionContext } from './discord/deferred.js'; +import { syncCommands, type RegistrationEnv } from './discord/registration.js'; +import { brandFor, parseBrandColor, type BrandConfig } from './brand.js'; + interface BrandSettings { + get(key: string): Promise; get(key: string, type: 'json'): Promise; put(key: string, value: string): Promise; } -interface Env { + +interface Env extends RegistrationEnv { DISCORD_PUBLIC_KEY: string; CAD_BASE_URL: string; BRAND_AVATAR_URL?: string; BRAND_SETTINGS: BrandSettings; } -function cadErrorResponse(error: unknown, brand: ReturnType): Response { + +// Each Worker instance checks the command definitions once, so a deploy that +// changes them takes effect with the first interaction rather than the next cron run. +let commandsChecked = false; + +function checkCommandsOnce(env: Env, ctx: ExecutionContext) { + if (commandsChecked) return; + commandsChecked = true; + ctx.waitUntil(syncCommands(env, env.BRAND_SETTINGS)); +} + +type InteractionOption = { + name: string; + value?: string; + focused?: boolean; + options?: InteractionOption[]; +}; + +interface Interaction { + type: number; + application_id: string; + token: string; + guild_id?: string; + member?: { + permissions?: string; + user?: { id: string }; + }; + user?: { id: string }; + data?: { + name?: string; + options?: InteractionOption[]; + custom_id?: string; + values?: string[]; + }; + message?: { + components?: unknown; + interaction_metadata?: { user?: { id: string } }; + }; +} + +const UNAVAILABLE_COMMAND = 'That command isn’t available right now. If the bot was just updated, try again in a moment.'; + +const ephemeral = (content: string): MessageData => ({ content, flags: EPHEMERAL }); + +const optionValue = (options: InteractionOption[] | undefined, name: string) => + options?.find((option) => option.name === name)?.value; + +async function loadBrand(interaction: Interaction, env: Env): Promise { + const storedColor = interaction.guild_id + ? await env.BRAND_SETTINGS.get(`color:${interaction.guild_id}`, 'json') + : null; + + return brandFor( + env.CAD_BASE_URL, + env.BRAND_AVATAR_URL, + typeof storedColor === 'number' ? storedColor : undefined + ); +} + +function cadErrorMessage(error: unknown, brand: BrandConfig, notFound?: string): MessageData { let content = 'The documentation server returned an error.'; if (error instanceof Error) { - if (error.message === 'CAD_UNREACHABLE') { + if (error.message === 'CAD_NOT_FOUND' && notFound) { + return errorMessage(brand, notFound, 'Page not found'); + } else if (error.message === 'CAD_UNREACHABLE') { content = 'I couldn’t reach the documentation server.'; } else if (error.message === 'CAD_INVALID_RESPONSE') { content = 'The documentation server returned an invalid response.'; } } - return Response.json(errorResponse(brand, content)); + return errorMessage(brand, content); } -export default { - async fetch(request: Request, env: Env): Promise { - if (request.method !== 'POST') { - return new Response('Not Found', { status: 404 }); + +async function searchMessage(query: string, env: Env, brand: BrandConfig): Promise { + const cadClient = new CadClient(env.CAD_BASE_URL); + + try { + const results = await cadClient.search(query); + const top = results[0]; + // 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); + return undefined; + }) + : undefined; + + return formatSearchResults(results, env.CAD_BASE_URL, query, brand, topPage); + } catch (error) { + console.error('CAD search failed:', error); + + return cadErrorMessage(error, brand); } +} - const signature = request.headers.get('X-Signature-Ed25519'); - const timestamp = request.headers.get('X-Signature-Timestamp'); - const body = await request.text(); +async function pageMessage(slug: string, header: string | undefined, env: Env, brand: BrandConfig): Promise { + try { + const page = await new CadClient(env.CAD_BASE_URL).getPage(slug); - if (!signature || !timestamp) { - return new Response('Invalid request signature', { - status: 401 - }); + return formatPageResult(page, env.CAD_BASE_URL, brand, header); + } catch (error) { + console.error('CAD page failed:', error); + + return cadErrorMessage(error, brand, `There’s no page at \`${slug}\`. Pick one of the suggestions while typing, or try \`/ask\`.`); } +} - const valid = await verifyKey( - body, - signature, - timestamp, - env.DISCORD_PUBLIC_KEY - ); +async function settingsMessage(interaction: Interaction, setting: InteractionOption | undefined, env: Env): Promise { + const permissions = BigInt(interaction.member?.permissions ?? '0'); + const canManageSettings = (permissions & 0x8n) === 0x8n || (permissions & 0x20n) === 0x20n; + const colorValue = optionValue(setting?.options, 'hex'); + const color = colorValue ? parseBrandColor(colorValue) : undefined; - if (!valid) { - return new Response('Invalid request signature', { - status: 401 - }); + if (!interaction.guild_id) { + return ephemeral('Colour settings are only available in a server.'); + } + if (!canManageSettings) { + return ephemeral('You need the Manage Server permission to change the embed colour.'); + } + if (setting?.name !== 'colour') { + return ephemeral(UNAVAILABLE_COMMAND); + } + if (color === undefined) { + return ephemeral('Use a six-digit hex colour such as `#ce0985`.'); } - type InteractionOption = { - name: string; - value?: string; - focused?: boolean; - options?: InteractionOption[]; - }; - const interaction = JSON.parse(body) as { - type: number; - guild_id?: string; - member?: { - permissions?: string; - }; - data?: { - name: string; - options?: InteractionOption[]; - }; - }; - const storedColor = interaction.guild_id - ? await env.BRAND_SETTINGS.get(`color:${interaction.guild_id}`, 'json') - : null; - const brand = brandFor( - env.CAD_BASE_URL, - env.BRAND_AVATAR_URL, - typeof storedColor === 'number' ? storedColor : undefined - ); + await env.BRAND_SETTINGS.put(`color:${interaction.guild_id}`, JSON.stringify(color)); + + return ephemeral(`Embed colour updated to \`#${color.toString(16).padStart(6, '0')}\`.`); +} + +async function commandMessage(interaction: Interaction, env: Env): Promise { + const command = interaction.data?.name; - // Discord PING - if (interaction.type === 1) { - return Response.json({ - type: 1 - }); + if (command === 'ask') { + const question = optionValue(interaction.data?.options, 'question'); + + return question ? searchMessage(question, env, await loadBrand(interaction, env)) : ephemeral('Type a question to search for.'); } - if (interaction.type === 4) { - if (interaction.data?.name !== 'docs') { - return Response.json({ - type: 8, - data: { - choices: [] - } - }); - } - const subcommand = interaction.data.options?.[0]; + if (command !== 'docs') { + return ephemeral(UNAVAILABLE_COMMAND); + } + + const subcommand = interaction.data?.options?.[0]; + + switch (subcommand?.name) { + case 'settings': + return settingsMessage(interaction, subcommand.options?.[0], env); + case 'page': { + const slug = optionValue(subcommand.options, 'slug'); - if (!subcommand) { - return Response.json({ - type: 8, - data: { - choices: [] - } - }); + return slug + ? pageMessage(slug, optionValue(subcommand.options, 'header'), env, await loadBrand(interaction, env)) + : ephemeral('Choose a page to open.'); } + case 'search': { + const query = optionValue(subcommand.options, 'query'); - if (subcommand.name !== 'page') { - return Response.json({ - type: 8, - data: { - choices: [] - } - }); + return query ? searchMessage(query, env, await loadBrand(interaction, env)) : ephemeral('Type something to search for.'); } + default: + return ephemeral(UNAVAILABLE_COMMAND); + } +} - const focusedOption = subcommand.options?.find( - (option) => option.focused - ); +async function componentMessage(interaction: Interaction, env: Env): Promise { + const value = interaction.data?.values?.[0]; - const query = focusedOption?.value ?? ''; - const cadClient = new CadClient(env.CAD_BASE_URL); + if (interaction.data?.custom_id !== SEARCH_SELECT_ID || !value) { + return ephemeral(UNAVAILABLE_COMMAND); + } - try { - if (focusedOption?.name === 'header') { - const slug = subcommand.options?.find((option) => option.name === 'slug')?.value; - if (!slug) { - return Response.json({ type: 8, data: { choices: [] } }); - } + const { slug, header } = parseResultValue(value); + const brand = await loadBrand(interaction, env); + let page: PageResult; - const page = await cadClient.getPage(slug); - return Response.json({ type: 8, data: { choices: headerChoices(page, query) } }); - } + try { + page = await new CadClient(env.CAD_BASE_URL).getPage(slug); + } catch (error) { + console.error('CAD page for search result failed:', error); - const suggestions = await cadClient.suggest(query, 'page'); + return cadErrorMessage(error, brand, 'That page has been moved or removed. Try searching again.'); + } + + // Only the person who searched can change the shared message; anyone else gets their own copy. + const searcher = interaction.message?.interaction_metadata?.user?.id; + const user = interaction.member?.user?.id ?? interaction.user?.id; - return Response.json({ - type: 8, - data: { - choices: suggestions.map((suggestion) => ({ - name: suggestion.title, - value: suggestion.slug - })) - } - }); - } catch (error) { - console.error('CAD autocomplete failed:', error); - - return Response.json({ - type: 8, - data: { - choices: [] - } - }); + if (searcher && searcher !== user) { + return { ...formatPageResult(page, env.CAD_BASE_URL, brand, header), flags: EPHEMERAL }; + } + + return formatSelectedResult(page, env.CAD_BASE_URL, brand, value, interaction.message?.components); +} + +async function autocompleteResponse(interaction: Interaction, env: Env): Promise { + const options = interaction.data?.name === 'ask' + ? interaction.data.options + : interaction.data?.name === 'docs' + ? interaction.data.options?.[0]?.options + : undefined; + const focusedOption = options?.find((option) => option.focused); + const query = focusedOption?.value ?? ''; + const isQuery = focusedOption?.name === 'query' || focusedOption?.name === 'question'; + // Autocomplete can't be deferred, so give up early rather than miss Discord's deadline. + const cadClient = new CadClient(env.CAD_BASE_URL, DEFER_AFTER_MS); + let choices: { name: string; value: string }[] = []; + + try { + if (focusedOption?.name === 'header') { + const slug = optionValue(options, 'slug'); + if (slug) choices = headerChoices(await cadClient.getPage(slug), query); + } else if (focusedOption?.name === 'slug') { + const suggestions = await cadClient.suggest(query, 'page'); + choices = autocompleteChoices(suggestions.map((suggestion) => ({ name: suggestion.title, value: suggestion.slug }))); + } else if (isQuery) { + const suggestions = query.trim() ? await cadClient.suggest(query, 'page') : []; + choices = queryChoices(query, suggestions.map((suggestion) => suggestion.title)); } + } catch (error) { + console.error('CAD autocomplete failed:', error); + choices = isQuery ? queryChoices(query, []) : []; } - if (interaction.type === 2) { - if (interaction.data?.name !== 'docs') { - return new Response('Unknown command', { - status: 400 - }); - } - - const subcommand = interaction.data.options?.[0]; - - if (subcommand?.name === 'settings') { - const permissions = BigInt(interaction.member?.permissions ?? '0'); - const canManageSettings = (permissions & 0x8n) === 0x8n || (permissions & 0x20n) === 0x20n; - const setting = subcommand.options?.[0]; - const colorValue = setting?.options?.find((option) => option.name === 'hex')?.value; - const color = colorValue ? parseBrandColor(colorValue) : undefined; - - if (!interaction.guild_id) { - return Response.json({ type: 4, data: { content: 'Colour settings are only available in a server.', flags: 64 } }); - } - if (!canManageSettings) { - return Response.json({ type: 4, data: { content: 'You need the Manage Server permission to change the embed colour.', flags: 64 } }); - } - if (color === undefined) { - return Response.json({ type: 4, data: { content: 'Use a six-digit hex colour such as `#ce0985`.', flags: 64 } }); - } - - await env.BRAND_SETTINGS.put(`color:${interaction.guild_id}`, JSON.stringify(color)); - return Response.json({ type: 4, data: { content: `Embed colour updated to \`#${color.toString(16).padStart(6, '0')}\`.`, flags: 64 } }); - } - - if (subcommand?.name === 'page') { - const slugOption = subcommand.options?.find( - (option) => option.name === 'slug' - ); - const headerOption = subcommand.options?.find( - (option) => option.name === 'header' - ); - - if (!slugOption?.value) { - return new Response('Missing slug', { - status: 400 - }); - } - - const cadClient = new CadClient(env.CAD_BASE_URL); - - try { - const page = await cadClient.getPage(slugOption.value); - const message = formatPageResult(page, env.CAD_BASE_URL, brand, headerOption?.value); - - return Response.json({ - type: 4, - data: { - ...message - } - }); - } catch (error) { - console.error('CAD page failed:', error); - - return cadErrorResponse(error, brand); - } - } - if (subcommand?.name !== 'search') { - return new Response('Unknown subcommand', { - status: 400 - }); - } - - const queryOption = subcommand.options?.find( - (option) => option.name === 'query' - ); - - if (!queryOption?.value) { - return new Response('Missing query', { - status: 400 - }); - } - - const cadClient = new CadClient(env.CAD_BASE_URL); - - try { - const results = await cadClient.search(queryOption.value); - - if (results.length === 0) { - return Response.json({ - type: 4, - data: { - ...formatSearchResults([], env.CAD_BASE_URL, queryOption.value, brand) - } - }); + + return Response.json({ type: 8, data: { choices } }); +} + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + if (request.method !== 'POST') { + return new Response('Not Found', { status: 404 }); } - const message = formatSearchResults( - results, - env.CAD_BASE_URL, - queryOption.value, - brand - ); - - return Response.json({ - type: 4, - data: { - ...message - } - }); - } catch (error) { - console.error('CAD search failed:', error); + const signature = request.headers.get('X-Signature-Ed25519'); + const timestamp = request.headers.get('X-Signature-Timestamp'); + const body = await request.text(); - return cadErrorResponse(error, brand); - } - } + if (!signature || !timestamp) { + return new Response('Invalid request signature', { status: 401 }); + } + + const valid = await verifyKey(body, signature, timestamp, env.DISCORD_PUBLIC_KEY); + + if (!valid) { + return new Response('Invalid request signature', { status: 401 }); + } - return new Response('Unknown interaction', { - status: 400 - }); - } + const interaction = JSON.parse(body) as Interaction; + + checkCommandsOnce(env, ctx); + + switch (interaction.type) { + // PING + case 1: + return Response.json({ type: 1 }); + // APPLICATION_COMMAND + case 2: { + const message = commandMessage(interaction, env).catch((error: unknown) => { + console.error('Command failed:', error); + + return ephemeral('Something went wrong. Please try again.'); + }); + + return respondInTime(message, interaction, ctx); + } + // MESSAGE_COMPONENT + case 3: { + const message = componentMessage(interaction, env).catch((error: unknown) => { + console.error('Component failed:', error); + + return ephemeral('Something went wrong. Please try again.'); + }); + + return respondInTime(message, interaction, ctx, { updatesMessage: true }); + } + // APPLICATION_COMMAND_AUTOCOMPLETE + case 4: + return autocompleteResponse(interaction, env); + default: + return new Response('Unknown interaction', { status: 400 }); + } + }, + + async scheduled(_controller: unknown, env: Env, ctx: ExecutionContext): Promise { + ctx.waitUntil(syncCommands(env, env.BRAND_SETTINGS)); + } }; diff --git a/src/register-commands.ts b/src/register-commands.ts index 6b9c309..af9ca80 100644 --- a/src/register-commands.ts +++ b/src/register-commands.ts @@ -1,88 +1,31 @@ -import 'dotenv/config'; -import { REST, Routes, SlashCommandBuilder } from 'discord.js'; +// Registers the slash commands manually. Deployed Workers with DISCORD_TOKEN set +// register them automatically, so this is only needed without that secret. +import { existsSync } from 'node:fs'; +import { commandsUrl, registerCommands } from './discord/registration.js'; -const token = process.env.DISCORD_TOKEN; -const clientId = process.env.DISCORD_CLIENT_ID; -const guildId = process.env.DISCORD_GUILD_ID; +if (existsSync('.env')) { + process.loadEnvFile('.env'); +} + +const { DISCORD_TOKEN, DISCORD_CLIENT_ID, DISCORD_GUILD_ID } = process.env; -if (!token) { +if (!DISCORD_TOKEN) { throw new Error('DISCORD_TOKEN is not configured'); } -if (!clientId) { +if (!DISCORD_CLIENT_ID) { throw new Error('DISCORD_CLIENT_ID is not configured'); } -const applicationClientId = clientId; - -const commands = [ - new SlashCommandBuilder() - .setName('docs') - .setDescription('Search and browse the documentation') - .addSubcommand((subcommand) => - subcommand - .setName('search') - .setDescription('Search the documentation') - .addStringOption((option) => - option - .setName('query') - .setDescription('What do you want to find?') - .setRequired(true) - ) - ) - .addSubcommand((subcommand) => - subcommand - .setName('page') - .setDescription('Open a documentation page') - .addStringOption((option) => - option - .setName('slug') - .setDescription('The documentation page slug') - .setRequired(true) - .setAutocomplete(true) - ) - .addStringOption((option) => - option - .setName('header') - .setDescription('A heading within the documentation page') - .setRequired(false) - .setAutocomplete(true) - ) - ) - .addSubcommandGroup((group) => - group - .setName('settings') - .setDescription('Configure this server’s documentation bot') - .addSubcommand((subcommand) => - subcommand - .setName('colour') - .setDescription('Set the embed colour for this server') - .addStringOption((option) => - option - .setName('hex') - .setDescription('Six-digit hex colour, for example #ce0985') - .setRequired(true) - ) - ) - ) -].map((command) => command.toJSON()); +const env = { DISCORD_TOKEN, DISCORD_CLIENT_ID, ...(DISCORD_GUILD_ID ? { DISCORD_GUILD_ID } : {}) }; -const rest = new REST({ version: '10' }).setToken(token); +console.log(`Registering application commands ${env.DISCORD_GUILD_ID ? `for server ${env.DISCORD_GUILD_ID}` : 'globally'}...`); -async function registerCommands() { - try { - console.log('Registering application commands...'); +const response = await registerCommands(env); - const route = guildId - ? Routes.applicationGuildCommands(applicationClientId, guildId) - : Routes.applicationCommands(applicationClientId); - - await rest.put(route, { body: commands }); - - console.log('Application commands registered.'); - } catch (error) { - console.error(error); - } +if (!response.ok) { + console.error(`Registration failed (${response.status}) at ${commandsUrl(env)}:`, await response.text()); + process.exitCode = 1; +} else { + console.log('Application commands registered.'); } - -registerCommands(); diff --git a/template/.dev.vars.example b/template/.dev.vars.example new file mode 100644 index 0000000..7f70e44 --- /dev/null +++ b/template/.dev.vars.example @@ -0,0 +1,2 @@ +DISCORD_PUBLIC_KEY= # Discord Developer Portal → your application → General Information → Public Key +DISCORD_TOKEN= # Discord Developer Portal → your application → Bot → Reset Token diff --git a/template/.github/dependabot.yml b/template/.github/dependabot.yml new file mode 100644 index 0000000..b4f0889 --- /dev/null +++ b/template/.github/dependabot.yml @@ -0,0 +1,12 @@ +# Opens a pull request when a new version of the bot is published. +# Merging it redeploys the bot through Workers Builds. +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: daily + allow: + - dependency-name: ctrlaltbot + commit-message: + prefix: ctrlaltbot diff --git a/template/.github/workflows/auto-update.yml b/template/.github/workflows/auto-update.yml new file mode 100644 index 0000000..a4b72f3 --- /dev/null +++ b/template/.github/workflows/auto-update.yml @@ -0,0 +1,25 @@ +# Merges bot updates that are marked as compatible (minor and patch versions), +# so fixes and new features deploy without any action from you. +# Major versions may need changes on your side, so they wait for you to merge. +# Delete this file to review every update yourself. +name: Auto-merge bot updates + +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + merge: + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - id: metadata + uses: dependabot/fetch-metadata@v2 + + - if: steps.metadata.outputs.update-type != 'version-update:semver-major' + run: gh pr merge "$PR_URL" --squash + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/template/.gitignore b/template/.gitignore new file mode 100644 index 0000000..b9b1065 --- /dev/null +++ b/template/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.dev.vars +.wrangler/ diff --git a/template/README.md b/template/README.md new file mode 100644 index 0000000..dba3776 --- /dev/null +++ b/template/README.md @@ -0,0 +1,24 @@ +# My ctrlaltbot + +This repository runs [ctrlaltbot](https://github.com/ctrl-alt-doc/ctrlaltbot), a Discord bot for a ctrl alt doc documentation site, on Cloudflare Workers. + +It contains only your configuration. The bot itself is the `ctrlaltbot` npm package. + +## Settings + +- **Docs site and Discord IDs**: `vars` in `wrangler.jsonc`. Commit a change here to redeploy with it. +- **Secrets** (`DISCORD_PUBLIC_KEY`, `DISCORD_TOKEN`): Cloudflare dashboard → Workers & Pages → your Worker → Settings → Variables and Secrets. + +## Updates + +Dependabot opens a pull request when a new version of the bot is published. Minor and patch versions are merged automatically by `.github/workflows/auto-update.yml`, which redeploys the bot. Major versions wait for you to merge them; check the [changelog](https://github.com/ctrl-alt-doc/ctrlaltbot/blob/main/CHANGELOG.md) first. + +Delete `.github/workflows/auto-update.yml` to review every update yourself. + +## Local development + +```bash +npm install +cp .dev.vars.example .dev.vars # then fill in the values +npm run dev +``` diff --git a/template/package.json b/template/package.json new file mode 100644 index 0000000..d0c0175 --- /dev/null +++ b/template/package.json @@ -0,0 +1,34 @@ +{ + "name": "my-ctrlaltbot", + "private": true, + "type": "module", + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev" + }, + "dependencies": { + "ctrlaltbot": "^1.1.0" + }, + "devDependencies": { + "wrangler": "^4.132.0" + }, + "cloudflare": { + "bindings": { + "CAD_BASE_URL": { + "description": "The address of your ctrl alt doc site, for example `https://docs.example.com`." + }, + "DISCORD_CLIENT_ID": { + "description": "Your Discord application's **Application ID**, from the [Discord Developer Portal](https://discord.com/developers/applications) → General Information." + }, + "DISCORD_PUBLIC_KEY": { + "description": "Your application's **Public Key**, from General Information. Used to verify that requests come from Discord." + }, + "DISCORD_TOKEN": { + "description": "Your bot's token, from the **Bot** page (select **Reset Token**). Used to keep the slash commands up to date." + }, + "BRAND_SETTINGS": { + "description": "Stores each server's settings, such as the embed colour." + } + } + } +} diff --git a/template/src/index.js b/template/src/index.js new file mode 100644 index 0000000..3450f32 --- /dev/null +++ b/template/src/index.js @@ -0,0 +1,3 @@ +// Your bot runs the published ctrlaltbot package. Updates arrive as Dependabot +// pull requests that bump its version; merging one redeploys the bot. +export { default } from 'ctrlaltbot'; diff --git a/template/wrangler.jsonc b/template/wrangler.jsonc new file mode 100644 index 0000000..1b6563b --- /dev/null +++ b/template/wrangler.jsonc @@ -0,0 +1,22 @@ +{ + "name": "ctrlaltbot", + "main": "src/index.js", + "compatibility_date": "2026-09-13", + "vars": { + "CAD_BASE_URL": "https://your-docs.example.com", + "DISCORD_CLIENT_ID": "YOUR_DISCORD_APPLICATION_ID" + }, + // Stores per-server settings. Created automatically on first deploy. + "kv_namespaces": [ + { + "binding": "BRAND_SETTINGS" + } + ], + // Keeps the slash commands up to date after each update. + "triggers": { + "crons": ["0 * * * *"] + }, + "observability": { + "enabled": true + } +} diff --git a/test/deferred.test.ts b/test/deferred.test.ts new file mode 100644 index 0000000..3889a4d --- /dev/null +++ b/test/deferred.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; + +import { respondInTime, sendDeferredReply } from '../src/discord/deferred.js'; + +const interaction = { application_id: 'app', token: 'tok' }; +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function recordFetch() { + const calls: { url: string; method: string; body?: unknown }[] = []; + + globalThis.fetch = (async (url: string, init?: RequestInit) => { + calls.push({ url, method: init?.method ?? 'GET', body: init?.body ? JSON.parse(init.body as string) : undefined }); + return new Response(null, { status: 204 }); + }) as typeof fetch; + + return calls; +} + +test('replies directly when the message is ready in time', async () => { + const pending: Promise[] = []; + const response = await respondInTime(Promise.resolve({ content: 'hi' }), interaction, { waitUntil: (p) => pending.push(p) }, { deferAfterMs: 50 }); + + assert.deepEqual(await response.json(), { type: 4, data: { content: 'hi' } }); + assert.equal(pending.length, 0); +}); + +test('defers slow messages and edits the original reply', async () => { + const calls = recordFetch(); + const pending: Promise[] = []; + const slow = new Promise<{ content: string }>((resolve) => setTimeout(resolve, 30, { content: 'done' })); + + const response = await respondInTime(slow, interaction, { waitUntil: (p) => pending.push(p) }, { deferAfterMs: 5 }); + assert.deepEqual(await response.json(), { type: 5 }); + + await Promise.all(pending); + assert.deepEqual(calls, [{ url: 'https://discord.com/api/v10/webhooks/app/tok/messages/@original', method: 'PATCH', body: { content: 'done' } }]); +}); + +test('sends late ephemeral messages as a follow-up and removes the public placeholder', async () => { + const calls = recordFetch(); + + await sendDeferredReply(interaction, { content: 'Page not found', flags: 64 }); + + assert.deepEqual(calls.map(({ method, url }) => `${method} ${url}`), [ + 'POST https://discord.com/api/v10/webhooks/app/tok', + 'DELETE https://discord.com/api/v10/webhooks/app/tok/messages/@original' + ]); + assert.deepEqual(calls[0]?.body, { content: 'Page not found', flags: 64 }); +}); + +test('updates the component message directly when ready in time', async () => { + const response = await respondInTime(Promise.resolve({ content: 'new' }), interaction, { waitUntil: () => {} }, { updatesMessage: true, deferAfterMs: 50 }); + + assert.deepEqual(await response.json(), { type: 7, data: { content: 'new' } }); +}); + +test('sends ephemeral component replies as new messages', async () => { + const response = await respondInTime(Promise.resolve({ content: 'mine', flags: 64 }), interaction, { waitUntil: () => {} }, { updatesMessage: true, deferAfterMs: 50 }); + + assert.deepEqual(await response.json(), { type: 4, data: { content: 'mine', flags: 64 } }); +}); + +test('defers slow component updates without a new thinking message', async () => { + const calls = recordFetch(); + const pending: Promise[] = []; + const slow = new Promise<{ content: string }>((resolve) => setTimeout(resolve, 30, { content: 'done' })); + + const response = await respondInTime(slow, interaction, { waitUntil: (p) => pending.push(p) }, { updatesMessage: true, deferAfterMs: 5 }); + assert.deepEqual(await response.json(), { type: 6 }); + + await Promise.all(pending); + assert.deepEqual(calls.map(({ method }) => method), ['PATCH']); +}); + +test('keeps the search message when a late component reply is ephemeral', async () => { + const calls = recordFetch(); + + await sendDeferredReply(interaction, { content: 'Page not found', flags: 64 }, true); + + assert.deepEqual(calls.map(({ method }) => method), ['POST']); +}); diff --git a/test/registration.test.ts b/test/registration.test.ts new file mode 100644 index 0000000..cdb210c --- /dev/null +++ b/test/registration.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; + +import { COMMANDS, commandsHash } from '../src/commands.js'; +import { syncCommands } from '../src/discord/registration.js'; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function memoryStore() { + const values = new Map(); + return { values, get: async (key: string) => values.get(key) ?? null, put: async (key: string, value: string) => void values.set(key, value) }; +} + +function recordFetch(status = 200) { + const calls: { url: string; method?: string; auth?: string; body: unknown }[] = []; + + globalThis.fetch = (async (url: string, init: RequestInit) => { + calls.push({ url, method: init.method, auth: (init.headers as Record).Authorization, body: JSON.parse(init.body as string) }); + return new Response('[]', { status }); + }) as typeof fetch; + + return calls; +} + +const env = { DISCORD_TOKEN: 'token', DISCORD_CLIENT_ID: 'app' }; + +test('registers commands once, then only when they change', async () => { + const calls = recordFetch(); + const store = memoryStore(); + + assert.equal(await syncCommands(env, store), 'registered'); + assert.equal(await syncCommands(env, store), 'unchanged'); + assert.deepEqual(calls.map(({ url, method, auth }) => ({ url, method, auth })), [ + { url: 'https://discord.com/api/v10/applications/app/commands', method: 'PUT', auth: 'Bot token' } + ]); + assert.deepEqual(calls[0]?.body, COMMANDS); + + store.values.set('commands:hash', 'outdated'); + assert.equal(await syncCommands(env, store), 'registered'); + assert.equal(calls.length, 2); +}); + +test('registers to a single server when a guild is configured', async () => { + const calls = recordFetch(); + const store = memoryStore(); + + await syncCommands(env, store); + assert.equal(await syncCommands({ ...env, DISCORD_GUILD_ID: 'guild' }, store), 'registered', 'switching target re-registers'); + assert.equal(calls[1]?.url, 'https://discord.com/api/v10/applications/app/guilds/guild/commands'); +}); + +test('retries later when Discord rejects the registration', async () => { + recordFetch(401); + const store = memoryStore(); + + assert.equal(await syncCommands(env, store), 'failed'); + assert.equal(store.values.size, 0); +}); + +test('does nothing without a bot token', async () => { + const calls = recordFetch(); + + assert.equal(await syncCommands({ DISCORD_CLIENT_ID: 'app' }, memoryStore()), 'not-configured'); + assert.equal(calls.length, 0); +}); + +test('fingerprints command definitions deterministically', async () => { + assert.equal(await commandsHash(), await commandsHash(structuredClone(COMMANDS))); + assert.notEqual(await commandsHash(), await commandsHash([...COMMANDS, { name: 'extra' }])); +}); diff --git a/test/responses.test.ts b/test/responses.test.ts new file mode 100644 index 0000000..c61b01e --- /dev/null +++ b/test/responses.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { brandFor } from '../src/brand.js'; +import { autocompleteChoices, formatSearchResults, formatSelectedResult, parseResultValue, queryChoices } from '../src/discord/responses.js'; + +const baseUrl = 'https://docs.example.com'; +const brand = brandFor(baseUrl); + +type Message = { + embeds: { title?: string; description?: string }[]; + components: { components: { type: number; url?: string; options?: { label: string; value: string; default?: boolean }[] }[] }[]; +}; + +const results = [ + { title: 'Cooking eggs', description: 'Boil and fry.', slug: 'cooking/eggs', excerpt: '', heading: { id: 'boiling-an-egg', title: 'Boiling an egg' } }, + { title: 'Pasta', description: '', slug: 'cooking/pasta', excerpt: '' } +]; + +const eggsPage = { + title: 'Cooking eggs', + description: 'Boil and fry.', + excerpt: '', + slug: 'cooking/eggs', + toc: [{ id: 'boiling-an-egg', title: 'Boiling an egg', level: 2 }, { id: 'frying', title: 'Frying', level: 2 }], + markdown: '## Boiling an egg\n\nPlace the egg in water.\n\n## Frying\n\nHeat the pan.' +}; + +const select = (message: Message) => message.components.flatMap((row) => row.components).find((component) => component.type === 3); +const link = (message: Message) => message.components.flatMap((row) => row.components).find((component) => component.type === 2)?.url; + +test('links the top search result to its matched heading', () => { + const message = formatSearchResults(results, baseUrl, 'how to boil an egg', brand) as Message; + + assert.equal(message.embeds[0]?.title, 'Cooking eggs › Boiling an egg'); + assert.equal(link(message), 'https://docs.example.com/cooking/eggs#boiling-an-egg'); +}); + +test('shows the matched section when the top page was fetched', () => { + const message = formatSearchResults(results, baseUrl, 'boil an egg', brand, eggsPage) as Message; + + assert.equal(message.embeds[0]?.title, 'Cooking eggs › Boiling an egg'); + assert.equal(message.embeds[0]?.description, 'Place the egg in water.'); +}); + +test('lists results in a menu with the shown result selected', () => { + const options = select(formatSearchResults(results, baseUrl, 'cooking', brand) as Message)?.options; + + assert.deepEqual(options?.map(({ label, value, default: selected }) => ({ label, value, selected })), [ + { label: 'Cooking eggs › Boiling an egg', value: 'cooking/eggs#boiling-an-egg', selected: true }, + { label: 'Pasta', value: 'cooking/pasta', selected: false } + ]); +}); + +test('omits the menu for a single result and skips values Discord would reject', () => { + assert.equal(select(formatSearchResults(results.slice(1), baseUrl, 'pasta', brand) as Message), undefined); + + const longSlug = { title: 'Long', description: '', slug: 'x'.repeat(101), excerpt: '' }; + assert.equal(select(formatSearchResults([results[1]!, longSlug], baseUrl, 'pasta', brand) as Message), undefined); +}); + +test('moves the selection when another result is chosen', () => { + const original = formatSearchResults(results, baseUrl, 'cooking', brand) as Message; + const updated = formatSelectedResult(eggsPage, baseUrl, brand, 'cooking/eggs#frying', original.components) as Message; + + assert.equal(updated.embeds[0]?.title, 'Cooking eggs › Frying'); + assert.equal(updated.embeds[0]?.description, 'Heat the pan.'); + assert.equal(link(updated), 'https://docs.example.com/cooking/eggs#frying'); + assert.deepEqual(select(updated)?.options?.map((option) => option.default), [false, false]); + + const pasta = formatSelectedResult({ ...eggsPage, title: 'Pasta', slug: 'cooking/pasta' }, baseUrl, brand, 'cooking/pasta', original.components) as Message; + assert.deepEqual(select(pasta)?.options?.map((option) => option.default), [false, true]); +}); + +test('parses result values', () => { + assert.deepEqual(parseResultValue('cooking/eggs#boiling-an-egg'), { slug: 'cooking/eggs', header: 'boiling-an-egg' }); + assert.deepEqual(parseResultValue('cooking/pasta'), { slug: 'cooking/pasta' }); +}); + +test('keeps the typed question as the first autocomplete choice', () => { + assert.deepEqual(queryChoices('how to cook an egg', ['Cooking eggs', 'Pasta']), [ + { name: 'Search: how to cook an egg', value: 'how to cook an egg' }, + { name: 'Cooking eggs', value: 'Cooking eggs' }, + { name: 'Pasta', value: 'Pasta' } + ]); + assert.deepEqual(queryChoices('pasta', ['Pasta']), [{ name: 'Search: pasta', value: 'pasta' }]); + assert.deepEqual(queryChoices(' ', []), []); +}); + +test('enforces Discord autocomplete limits', () => { + const choices = autocompleteChoices([ + { name: 'x'.repeat(150), value: 'long-name' }, + { name: 'Too long', value: 'y'.repeat(101) }, + ...Array.from({ length: 30 }, (_, index) => ({ name: `Page ${index}`, value: `page-${index}` })) + ]); + + assert.equal(choices.length, 25); + assert.equal(choices[0]?.name.length, 100); + assert.ok(choices.every((choice) => choice.value.length <= 100)); +}); diff --git a/test/sections.test.ts b/test/sections.test.ts new file mode 100644 index 0000000..0bb02f5 --- /dev/null +++ b/test/sections.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { PageResult } from '../src/cad/types.js'; +import { sectionText, truncateMarkdown } from '../src/discord/sections.js'; + +const toc = [ + { id: 'boiling', title: 'Boiling', level: 2 }, + { id: 'timing', title: 'Timing', level: 3 }, + { id: 'frying', title: 'Frying', level: 2 }, + { id: 'notes', title: 'Notes', level: 2 }, + { id: 'notes-2', title: 'Notes', level: 2 } +]; + +const markdown = [ + '## Boiling', + '', + 'Place the **egg** in water.', + '', + '```bash', + '# not a heading', + 'boil --minutes 6', + '```', + '', + '### Timing', + '', + 'Six minutes.', + '', + '## Frying', + '', + ':::tip[Hot pan]', + 'Heat the pan first.', + ':::', + '', + ':::warning', + 'Mind the oil.', + ':::', + '', + '## Notes', + '', + 'First notes.', + '', + '## Notes', + '', + 'Second notes.' +].join('\n'); + +const page = (fields: Partial): PageResult => ({ title: 'Eggs', description: '', excerpt: '', slug: 'eggs', toc, ...fields }); + +test('stops at the next heading of the same level', () => { + const text = sectionText(page({ markdown }), toc[0]!); + + assert.ok(text?.includes('boil --minutes 6')); + assert.ok(text?.includes('**Timing**'), 'subheadings stay, as bold text'); + assert.ok(!text?.includes('Frying')); +}); + +test('keeps code blocks and ignores # lines inside them', () => { + const text = sectionText(page({ markdown }), toc[0]!); + + assert.match(text ?? '', /```bash\n# not a heading\nboil --minutes 6\n```/); +}); + +test('turns directives into bold titles', () => { + assert.equal(sectionText(page({ markdown }), toc[2]!), '**Hot pan**\nHeat the pan first.\n\n**Warning**\nMind the oil.'); +}); + +test('finds the right occurrence of a repeated heading', () => { + assert.equal(sectionText(page({ markdown }), toc[3]!), 'First notes.'); + assert.equal(sectionText(page({ markdown }), toc[4]!), 'Second notes.'); +}); + +test('falls back to rendered HTML when CAD sends no Markdown', () => { + const content = [ + '

Boiling #

', + '

Place the egg & water in a pan.

', + '
ts
boil();
', + '

Timing

Six minutes.

', + '

Frying

Heat the pan.

' + ].join('\n'); + + const text = sectionText(page({ content }), toc[0]!) ?? ''; + + assert.match(text, /Place the egg & water in a pan\./); + assert.match(text, /boil\(\);/); + assert.match(text, /Six minutes\./); + assert.ok(!text.includes('Heat the pan')); + assert.ok(!text.includes('Copy') && !text.includes('ts\n')); +}); + +test('returns nothing for a heading that is not on the page', () => { + assert.equal(sectionText(page({ markdown }), { id: 'missing', title: 'Missing', level: 2 }), undefined); +}); + +test('closes a code block cut off by truncation', () => { + const text = truncateMarkdown(`Intro\n\n\`\`\`ts\n${'const value = 1;\n'.repeat(20)}\`\`\``, 100); + + assert.ok(text.length <= 100); + assert.equal((text.match(/```/g) ?? []).length % 2, 0); + assert.ok(text.endsWith('…')); +}); diff --git a/wrangler.jsonc b/wrangler.jsonc index ac4cc44..3452b2a 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,15 +1,24 @@ +// Development configuration for working on the bot itself. +// To run your own bot, use the template in `template/` instead. { - "name": "cad-discord-your-name", + "name": "ctrlaltbot-dev", "main": "src/index.ts", "compatibility_date": "2026-09-13", "vars": { "CAD_BASE_URL": "https://your-docs.example.com", - "DISCORD_CLIENT_ID": "YOUR_DISCORD_APPLICATION_ID" + "DISCORD_CLIENT_ID": "YOUR_DISCORD_APPLICATION_ID" }, + // Created automatically on first deploy. "kv_namespaces": [ { - "binding": "BRAND_SETTINGS", - "id": "REPLACE_WITH_YOUR_BRAND_SETTINGS_NAMESPACE_ID" + "binding": "BRAND_SETTINGS" } - ] + ], + // Re-registers slash commands hourly if their definitions have changed. + "triggers": { + "crons": ["0 * * * *"] + }, + "observability": { + "enabled": true + } }